theseus/bundled-addons/blocker/index.js

214 lines
10 KiB
JavaScript
Raw Normal View History

// 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"));
// 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 };