theseus/bundled-addons/blocker/index.js
Local Dev 053d7bc127 feat(theseus): Shield — tracker and ad blocking as a bundled add-on
Theseus had no content blocking at all. Shield blocks requests to known
tracking and advertising hosts on every site, using EasyList and
EasyPrivacy through Ghostery's adblocker engine (the matcher those lists
are written for). The lists ship inside the add-on so blocking works
from the first launch, offline; the compiled engine is cached under the
add-on's data dir (a 22 ms load instead of a 500 ms parse), and the
lists refresh from their publishers about once a day.

The panel shows what was stopped on the current page, a one-click
allow for the site, the global switch, the running total and the rule
versions with an "Update now". Network filters only for now: a blocked
request never leaves the browser, but leftover empty ad boxes are not
hidden yet.

Host side: a "request-filter" capability. Chromium allows one
onBeforeRequest listener per session, so main owns it and consults the
add-ons' filters; a top-level navigation is never blocked, only http(s)
subresources are offered. api.tabs (active tab and a change event) lets
the panel show per-site numbers without seeing page content.
2026-09-27 19:43:14 +02:00

213 lines
10 KiB
JavaScript

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