theseus/bundled-addons/blocker/panel.html
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

124 lines
8 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Shield</title>
<style>
:root { color-scheme: light dark;
--bg:#0e131c; --panel:#141a24; --panel2:#191f2b; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#0ac18e; --blue:#4c9eff; --warn:#f5a524; --danger:#ff5b5b; }
@media (prefers-color-scheme: light) {
:root { --bg:#f8faff; --panel:#ffffff; --panel2:#eff3fb; --line:rgba(0,0,0,.10);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --blue:#2b6fd6; }
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body { background: var(--bg); color: var(--ink); font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; display: flex; flex-direction: column; }
header { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: var(--panel); }
header h1 { margin: 0; font-size: 14px; font-weight: 600; }
header .pill { margin-left: auto; }
.pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px;
font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
background: rgba(139,152,169,.14); color: var(--mut); }
.pill.on { color: #0b0e14; background: var(--acid); }
.pill.off { color: #fff; background: var(--danger); }
.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.body { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 12px; }
.card { border: 1px solid var(--line); background: var(--panel); border-radius: 14px; padding: 16px; }
.site { display: flex; flex-direction: column; align-items: center; gap: 6px; text-align: center; }
.site .host { font: 600 13px/1.3 ui-monospace, "Cascadia Code", Consolas, monospace; color: var(--mut); overflow-wrap: anywhere; }
.site .num { font-size: 40px; font-weight: 700; line-height: 1.1; color: var(--blue); font-variant-numeric: tabular-nums; }
.site .num.allowed { color: var(--dim); }
.site .lbl { color: var(--mut); }
.site .sub { color: var(--dim); font-size: 11.5px; }
.btn { border: 1px solid var(--line); background: var(--panel2); color: var(--ink); padding: 9px 16px; border-radius: 10px;
font: inherit; font-weight: 600; cursor: pointer; }
.btn:hover { border-color: rgb(from var(--blue) r g b / .5); }
.btn.primary { background: var(--blue); color: #fff; border-color: transparent; }
.btn.quiet { background: transparent; }
.btn:disabled { opacity: .5; cursor: default; }
.btn.wide { margin-top: 10px; min-width: 200px; }
.row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.row + .row { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); }
.row .k { font-weight: 600; }
.row .d { color: var(--dim); font-size: 11.5px; }
.row .v { color: var(--mut); font-variant-numeric: tabular-nums; text-align: right; }
.switch { position: relative; width: 40px; height: 22px; border-radius: 11px; background: var(--dim); border: none; cursor: pointer; flex: none; transition: background .15s; }
.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: left .15s; }
.switch.on { background: var(--acid); } .switch.on::after { left: 20px; }
.note { color: var(--dim); font-size: 11.5px; line-height: 1.45; }
.note.warn { color: var(--warn); }
h2 { margin: 0 0 8px; font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--dim); }
</style>
</head>
<body>
<header><span>🛡</span><h1>Shield</h1><span class="pill" id="pill"><span class="dot"></span><span id="pillText">…</span></span></header>
<div class="body">
<div class="card site" id="siteCard">
<div class="lbl" id="siteLbl">Open a site to see what Shield stops there.</div>
</div>
<div class="card">
<div class="row">
<div><div class="k">Block trackers and ads</div><div class="d">On every site, using EasyList and EasyPrivacy.</div></div>
<button class="switch" id="enabled" role="switch" aria-checked="true" title="Turn Shield on or off"></button>
</div>
<div class="row">
<div><div class="k">Blocked since install</div><div class="d">Requests that never left the browser.</div></div>
<div class="v" id="total">0</div>
</div>
</div>
<div class="card">
<h2>Rules</h2>
<div id="lists"></div>
<div class="row" style="margin-top:12px; padding-top:12px; border-top:1px solid var(--line)">
<div class="note" id="updated"></div>
<button class="btn quiet" id="refresh">Update now</button>
</div>
</div>
<div class="note">Shield stops requests to known tracking and advertising hosts before they leave Theseus. It does not hide leftover empty ad boxes yet. If a site misbehaves, allow it here and reload.</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" })[c]);
const ago = (t) => { if (!t) return "bundled with the add-on"; const m = Math.round((Date.now() - t) / 60000); if (m < 2) return "updated just now"; if (m < 90) return `updated ${m} min ago`; const h = Math.round(m / 60); if (h < 36) return `updated ${h} h ago`; return `updated ${Math.round(h / 24)} days ago`; };
let state = null, hintHost = null;
function render(s) {
state = s;
const on = s.enabled && s.ready;
$("pill").className = "pill " + (s.enabled ? (s.ready ? "on" : "") : "off");
$("pillText").textContent = !s.enabled ? "Off" : !s.ready ? "Loading" : "On";
$("enabled").classList.toggle("on", s.enabled); $("enabled").setAttribute("aria-checked", String(s.enabled));
$("total").textContent = Number(s.blockedTotal || 0).toLocaleString();
const card = $("siteCard");
if (!s.site) {
card.innerHTML = `<div class="lbl">Open a site to see what Shield stops there.</div>`;
} else if (s.site.allowed) {
card.innerHTML = `<div class="host">${esc(s.site.host)}</div><div class="num allowed">—</div><div class="lbl">Allowed: nothing is blocked on this site.</div>
<button class="btn primary wide" id="siteBtn">Block on this site</button>`;
} else {
card.innerHTML = `<div class="host">${esc(s.site.host)}</div><div class="num">${on ? Number(s.site.blocked).toLocaleString() : "—"}</div>
<div class="lbl">${on ? (s.site.blocked === 1 ? "request blocked on this page" : "requests blocked on this page") : "Shield is off"}</div>
<div class="sub">${esc(s.site.requests)} requests seen</div>
<button class="btn wide" id="siteBtn">Allow this site</button>`;
}
const btn = $("siteBtn");
if (btn) btn.onclick = async () => {
const next = !s.site.allowed;
hintHost = s.site.host;
await window.silentmode.invoke("allow-site", { host: s.site.host, allowed: next });
};
if (s.site && hintHost === s.site.host) card.insertAdjacentHTML("beforeend", `<div class="sub">Reload the page to apply.</div>`);
else hintHost = null;
const L = s.lists || { versions: {}, titles: {} };
$("lists").innerHTML = Object.keys(L.titles || {}).map((id) => `<div class="row"><div class="k">${esc(L.titles[id])}</div><div class="v">${esc(L.versions[id] || "—")}</div></div>`).join("");
$("updated").textContent = (L.source === "online" ? "Rules " + ago(L.updatedAt) : "Rules bundled with the add-on") + (s.refreshing ? " · refreshing…" : "");
$("refresh").disabled = !!s.refreshing;
}
$("enabled").onclick = () => window.silentmode.invoke("set-enabled", { enabled: !(state && state.enabled) }).then(render);
$("refresh").onclick = () => { $("refresh").disabled = true; window.silentmode.invoke("refresh-lists").then((r) => { if (r && !r.ok) $("updated").textContent = "Update failed: " + (r.error || "unknown"); }); };
window.silentmode.on("state", render);
window.silentmode.invoke("state").then(render);
window.silentmode.onVisibility((v) => { if (v) window.silentmode.invoke("state").then(render); });
</script>
</body>
</html>