// 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")); api.onMessage("open-settings", () => { api.openSettings("privacy"); return true; }); api.onMessage("list-exceptions", () => [...allow].sort()); // 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 };