2026-09-27 19:43:14 +02:00
|
|
|
// Shield — tracker and ad blocking for every tab.
|
|
|
|
|
//
|
|
|
|
|
// The engine is Ghostery's adblocker (the same matcher uBlock-style lists
|
|
|
|
|
// are written for), fed with EasyList + EasyPrivacy. The lists ship inside
|
|
|
|
|
// the add-on so blocking works from the first launch, offline; a compiled
|
|
|
|
|
// copy of the engine is cached under the add-on's data dir so later starts
|
|
|
|
|
// load in milliseconds instead of re-parsing 3.7 MB of rules, and the lists
|
|
|
|
|
// are refreshed from their publishers about once a day in the background.
|
|
|
|
|
//
|
|
|
|
|
// Main owns the session's request hook and asks this add-on for a verdict
|
|
|
|
|
// on each subresource (never a top-level navigation). Network filters only:
|
|
|
|
|
// a blocked request never reaches the tracker at all. Cosmetic (element
|
|
|
|
|
// hiding) rules are not applied in this version.
|
|
|
|
|
const fs = require("node:fs");
|
|
|
|
|
const fsp = require("node:fs/promises");
|
|
|
|
|
const path = require("node:path");
|
|
|
|
|
|
|
|
|
|
const LISTS = [
|
|
|
|
|
{ id: "easylist", file: "easylist.txt", url: "https://easylist.to/easylist/easylist.txt", title: "EasyList" },
|
|
|
|
|
{ id: "easyprivacy", file: "easyprivacy.txt", url: "https://easylist.to/easylist/easyprivacy.txt", title: "EasyPrivacy" },
|
|
|
|
|
];
|
|
|
|
|
const CACHE_SUBDIR = "blocker";
|
|
|
|
|
const ENGINE_FILE = "engine.bin";
|
|
|
|
|
const META_FILE = "engine.json";
|
|
|
|
|
const CACHE_FORMAT = 1;
|
|
|
|
|
const REFRESH_MS = 24 * 60 * 60_000;
|
|
|
|
|
const REFRESH_CHECK_MS = 60 * 60_000;
|
|
|
|
|
const FIRST_REFRESH_DELAY_MS = 90_000;
|
|
|
|
|
const FETCH_TIMEOUT_MS = 60_000;
|
|
|
|
|
// Electron resourceType → the request types the lists are written against.
|
|
|
|
|
const TYPE_MAP = {
|
|
|
|
|
mainFrame: "main_frame", subFrame: "sub_frame", stylesheet: "stylesheet", script: "script", image: "image",
|
|
|
|
|
font: "font", object: "object", xhr: "xmlhttprequest", ping: "ping", cspReport: "csp_report", media: "media",
|
|
|
|
|
webSocket: "websocket", other: "other",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let api = null;
|
|
|
|
|
let FiltersEngine = null, Request = null;
|
|
|
|
|
let engine = null;
|
|
|
|
|
let enabled = true;
|
|
|
|
|
let allow = new Set(); // hosts the user allowed
|
|
|
|
|
let counts = new Map(); // webContentsId -> { host, blocked, total }
|
|
|
|
|
let blockedTotal = 0;
|
|
|
|
|
let listMeta = { updatedAt: 0, source: "bundled", versions: {} };
|
|
|
|
|
let unregister = null, unsubTabs = null;
|
|
|
|
|
let refreshTimer = null, refreshing = false;
|
|
|
|
|
let emitTimer = null, saveTimer = null;
|
|
|
|
|
|
|
|
|
|
const hostOf = (u) => { try { return new URL(u).host; } catch { return ""; } };
|
|
|
|
|
const cacheDir = () => path.join(api.dataDir, CACHE_SUBDIR);
|
|
|
|
|
const listVersion = (text) => (/^!\s*Version:\s*(\S+)/im.exec(text) || [])[1] || "";
|
|
|
|
|
|
|
|
|
|
// ---- engine ------------------------------------------------------------------
|
|
|
|
|
function buildEngine(texts) {
|
|
|
|
|
const t0 = Date.now();
|
|
|
|
|
const e = FiltersEngine.parse(texts.join("\n"), { loadCosmeticFilters: false, loadNetworkFilters: true, enableCompression: false });
|
|
|
|
|
api.log(`engine built from ${texts.length} lists in ${Date.now() - t0} ms`);
|
|
|
|
|
return e;
|
|
|
|
|
}
|
|
|
|
|
async function readBundledLists() {
|
|
|
|
|
const out = [];
|
|
|
|
|
for (const l of LISTS) out.push({ ...l, text: await fsp.readFile(path.join(api.folder, "lists", l.file), "utf8") });
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
async function saveCache(e, meta) {
|
|
|
|
|
await fsp.mkdir(cacheDir(), { recursive: true });
|
|
|
|
|
await fsp.writeFile(path.join(cacheDir(), ENGINE_FILE), Buffer.from(e.serialize()));
|
|
|
|
|
await fsp.writeFile(path.join(cacheDir(), META_FILE), JSON.stringify({ format: CACHE_FORMAT, ...meta }));
|
|
|
|
|
}
|
|
|
|
|
async function loadCache() {
|
|
|
|
|
try {
|
|
|
|
|
const meta = JSON.parse(await fsp.readFile(path.join(cacheDir(), META_FILE), "utf8"));
|
|
|
|
|
if (meta.format !== CACHE_FORMAT) return null;
|
|
|
|
|
const buf = await fsp.readFile(path.join(cacheDir(), ENGINE_FILE));
|
|
|
|
|
const e = FiltersEngine.deserialize(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
|
|
|
|
|
return { engine: e, meta };
|
|
|
|
|
} catch { return null; }
|
|
|
|
|
}
|
|
|
|
|
function installEngine(e, meta) {
|
|
|
|
|
engine = e;
|
|
|
|
|
listMeta = { updatedAt: meta.updatedAt || 0, source: meta.source || "bundled", versions: meta.versions || {} };
|
|
|
|
|
api.storage.set("listMeta", listMeta);
|
|
|
|
|
pushState();
|
|
|
|
|
}
|
|
|
|
|
// Cached engine when its bundled-list versions still match what ships;
|
|
|
|
|
// otherwise parse the bundled lists (first run, or after an update of the
|
|
|
|
|
// add-on brought newer lists) and cache the result.
|
|
|
|
|
async function ensureEngine() {
|
|
|
|
|
const bundled = await readBundledLists();
|
|
|
|
|
const bundledVersions = Object.fromEntries(bundled.map((l) => [l.id, listVersion(l.text)]));
|
|
|
|
|
const cached = await loadCache();
|
|
|
|
|
if (cached && cached.meta.bundledVersions && LISTS.every((l) => cached.meta.bundledVersions[l.id] === bundledVersions[l.id])) {
|
|
|
|
|
installEngine(cached.engine, cached.meta);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const e = buildEngine(bundled.map((l) => l.text));
|
|
|
|
|
const meta = { updatedAt: 0, source: "bundled", versions: bundledVersions, bundledVersions };
|
|
|
|
|
installEngine(e, meta);
|
|
|
|
|
saveCache(e, meta).catch((err) => api.log("cache write failed:", err?.message));
|
|
|
|
|
}
|
|
|
|
|
async function fetchText(url) {
|
|
|
|
|
const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
|
|
|
|
|
try {
|
|
|
|
|
const r = await fetch(url, { signal: ctl.signal, cache: "no-store" });
|
|
|
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
|
|
|
const text = await r.text();
|
|
|
|
|
if (!/^\[Adblock/im.test(text.slice(0, 200)) && !/^!\s*Title:/im.test(text.slice(0, 2000))) throw new Error("not a filter list");
|
|
|
|
|
return text;
|
|
|
|
|
} finally { clearTimeout(t); }
|
|
|
|
|
}
|
|
|
|
|
// Pull fresh lists from their publishers and rebuild. Keeps the current
|
|
|
|
|
// engine on any failure — a stale list beats no list.
|
|
|
|
|
async function refreshLists(reason) {
|
|
|
|
|
if (refreshing) return { ok: false, error: "already refreshing" };
|
|
|
|
|
refreshing = true; pushState();
|
|
|
|
|
try {
|
|
|
|
|
const texts = [], versions = {};
|
|
|
|
|
for (const l of LISTS) { const text = await fetchText(l.url); texts.push(text); versions[l.id] = listVersion(text); }
|
|
|
|
|
const bundled = await readBundledLists();
|
|
|
|
|
const e = buildEngine(texts);
|
|
|
|
|
const meta = { updatedAt: Date.now(), source: "online", versions, bundledVersions: Object.fromEntries(bundled.map((l) => [l.id, listVersion(l.text)])) };
|
|
|
|
|
installEngine(e, meta);
|
|
|
|
|
await saveCache(e, meta);
|
|
|
|
|
api.log(`lists refreshed (${reason}): ${Object.entries(versions).map(([k, v]) => k + " " + v).join(", ")}`);
|
|
|
|
|
return { ok: true, versions };
|
|
|
|
|
} catch (err) {
|
|
|
|
|
api.log(`list refresh failed (${reason}):`, err?.message);
|
|
|
|
|
return { ok: false, error: err?.message || "refresh failed" };
|
|
|
|
|
} finally { refreshing = false; pushState(); }
|
|
|
|
|
}
|
|
|
|
|
function scheduleRefresh() {
|
|
|
|
|
clearInterval(refreshTimer);
|
|
|
|
|
const due = () => Date.now() - (listMeta.updatedAt || 0) > REFRESH_MS;
|
|
|
|
|
setTimeout(() => { if (due()) refreshLists("startup").catch(() => {}); }, FIRST_REFRESH_DELAY_MS);
|
|
|
|
|
refreshTimer = setInterval(() => { if (due()) refreshLists("interval").catch(() => {}); }, REFRESH_CHECK_MS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- the verdict -----------------------------------------------------------------
|
|
|
|
|
function decide(r) {
|
|
|
|
|
const wc = r.webContentsId;
|
|
|
|
|
if (r.resourceType === "mainFrame") {
|
|
|
|
|
if (wc != null) counts.set(wc, { host: hostOf(r.url), blocked: 0, total: 0 });
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (!enabled || !engine) return false;
|
|
|
|
|
let entry = wc != null ? counts.get(wc) : null;
|
|
|
|
|
if (!entry && wc != null) { entry = { host: hostOf(r.frameUrl || r.initiator), blocked: 0, total: 0 }; counts.set(wc, entry); }
|
|
|
|
|
if (entry) entry.total++;
|
|
|
|
|
const site = entry ? entry.host : hostOf(r.frameUrl || r.initiator);
|
|
|
|
|
if (site && allow.has(site)) return false;
|
|
|
|
|
const req = Request.fromRawDetails({ url: r.url, type: TYPE_MAP[r.resourceType] || "other", sourceUrl: r.frameUrl || r.initiator || "" });
|
|
|
|
|
const m = engine.match(req);
|
|
|
|
|
if (!m.match) return false;
|
|
|
|
|
if (entry) entry.blocked++;
|
|
|
|
|
blockedTotal++;
|
|
|
|
|
scheduleSave(); pushStateSoon();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- state for the panel ---------------------------------------------------------
|
|
|
|
|
function snapshot() {
|
|
|
|
|
let tab = null; try { tab = api.tabs.active(); } catch {}
|
|
|
|
|
const host = tab ? tab.host : "";
|
|
|
|
|
const c = tab && tab.webContentsId != null ? counts.get(tab.webContentsId) : null;
|
|
|
|
|
const httpPage = !!tab && /^https?:/i.test(tab.url || "");
|
|
|
|
|
return {
|
|
|
|
|
enabled, refreshing, blockedTotal,
|
|
|
|
|
site: httpPage && host ? { host, allowed: allow.has(host), blocked: c ? c.blocked : 0, requests: c ? c.total : 0 } : null,
|
|
|
|
|
lists: { updatedAt: listMeta.updatedAt, source: listMeta.source, versions: listMeta.versions, titles: Object.fromEntries(LISTS.map((l) => [l.id, l.title])) },
|
|
|
|
|
ready: !!engine,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
function pushState() { try { api.emit("state", snapshot()); } catch {} }
|
|
|
|
|
function pushStateSoon() { if (emitTimer) return; emitTimer = setTimeout(() => { emitTimer = null; pushState(); }, 250); }
|
|
|
|
|
function scheduleSave() { if (saveTimer) return; saveTimer = setTimeout(() => { saveTimer = null; api.storage.set("blockedTotal", blockedTotal); }, 2000); }
|
|
|
|
|
function persistAllow() { api.storage.set("allow", [...allow]); }
|
|
|
|
|
|
|
|
|
|
// ---- lifecycle -------------------------------------------------------------------
|
|
|
|
|
async function activate(a) {
|
|
|
|
|
api = a;
|
|
|
|
|
({ FiltersEngine, Request } = api.require("@ghostery/adblocker"));
|
|
|
|
|
enabled = api.storage.get("enabled", true) !== false;
|
|
|
|
|
allow = new Set((api.storage.get("allow", []) || []).map(String));
|
|
|
|
|
blockedTotal = Number(api.storage.get("blockedTotal", 0)) || 0;
|
|
|
|
|
listMeta = api.storage.get("listMeta", listMeta) || listMeta;
|
|
|
|
|
|
|
|
|
|
api.registerSidebarPanel({ id: "main", title: "Shield", page: "panel.html" });
|
|
|
|
|
api.onMessage("state", () => snapshot());
|
|
|
|
|
api.onMessage("set-enabled", (p) => { enabled = !!(p && p.enabled); api.storage.set("enabled", enabled); pushState(); return snapshot(); });
|
|
|
|
|
api.onMessage("allow-site", (p) => {
|
|
|
|
|
const host = String((p && p.host) || "").toLowerCase(); if (!host) return snapshot();
|
|
|
|
|
if (p.allowed) allow.add(host); else allow.delete(host);
|
|
|
|
|
persistAllow(); pushState(); return snapshot();
|
|
|
|
|
});
|
|
|
|
|
api.onMessage("refresh-lists", () => refreshLists("manual"));
|
2026-09-27 20:37:30 +02:00
|
|
|
api.onMessage("open-settings", () => { api.openSettings("performance"); return true; });
|
2026-09-27 19:43:14 +02:00
|
|
|
|
|
|
|
|
// Filter first, engine second: requests made while the engine builds pass
|
|
|
|
|
// through (a few hundred milliseconds on first run), which beats holding
|
|
|
|
|
// the browser's startup for a parse.
|
|
|
|
|
unregister = api.registerRequestFilter(decide);
|
|
|
|
|
unsubTabs = api.tabs.onChange(() => pushState());
|
|
|
|
|
try { await ensureEngine(); } catch (err) { api.log("engine unavailable:", err?.message); }
|
|
|
|
|
scheduleRefresh();
|
|
|
|
|
api.log(`active — ${enabled ? "on" : "off"}, ${allow.size} allowed site(s), lists ${listMeta.source}`);
|
|
|
|
|
}
|
|
|
|
|
function deactivate() {
|
|
|
|
|
try { unregister && unregister(); } catch {}
|
|
|
|
|
try { unsubTabs && unsubTabs(); } catch {}
|
|
|
|
|
clearInterval(refreshTimer); clearTimeout(emitTimer); clearTimeout(saveTimer);
|
|
|
|
|
if (saveTimer) api.storage.set("blockedTotal", blockedTotal);
|
|
|
|
|
engine = null;
|
|
|
|
|
}
|
|
|
|
|
module.exports = { activate, deactivate };
|