diff --git a/PRIVACY.md b/PRIVACY.md index d8eb031..488c240 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -14,13 +14,11 @@ Les données suivantes sont stockées **uniquement** dans `chrome.storage.local` | Donnée | Pourquoi | Partagée ? | |---|---|---| | Préférences on/off (blocage réseau, cosmétique) | Conserver votre configuration | Non | -| Compteur de publicités bloquées | Affichage statistique dans le popup | Non | | Liste de filtres personnalisés (domaines saisis par vous) | Construire des règles `declarativeNetRequest` locales | Non | | Whitelist de sites autorisés | Désactiver le blocage sur les sites choisis | Non | -| Top domaines bloqués | Affichage dans le dashboard | Non | | État de pause (`pausedUntil`) | Pause temporaire de la protection | Non | -Ces données **ne quittent jamais votre navigateur**. Elles peuvent être effacées à tout moment via la désinstallation de l'extension ou le bouton *Réinitialiser* du dashboard. +Ces données **ne quittent jamais votre navigateur**. Elles peuvent être effacées à tout moment via la désinstallation de l'extension. ## Permissions et justification @@ -29,7 +27,7 @@ Ces données **ne quittent jamais votre navigateur**. Elles peuvent être effac | `declarativeNetRequest` | Bloquer les requêtes publicitaires via des règles statiques et dynamiques. | | `storage` | Enregistrer localement les préférences listées ci-dessus. | | `alarms` | Reprendre automatiquement le blocage après une pause temporaire. | -| `activeTab` | Lire le domaine de l'onglet actif **uniquement quand vous cliquez sur l'icône** (pour proposer le bouton « Autoriser ici »). | +| `activeTab` | Lire le domaine de l'onglet actif **uniquement quand vous cliquez sur l'icône** (pour proposer le bouton « Autoriser ici » et afficher le nombre de requêtes bloquées sur cette page, compté par Chrome). | | `host_permissions: ` | Appliquer les règles de blocage réseau et le nettoyage cosmétique sur tous les sites que vous visitez. Aucune donnée de navigation n'est lue, transmise ou stockée. | ## Ce que nous ne faisons PAS diff --git a/README.md b/README.md index 26f03d4..57b9d0f 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,10 @@ GoaBlockAD blocks ads at two levels: **network requests** are killed before they - **Network-level blocking** — Requests to Google Ads, Amazon, Criteo, Taboola, Outbrain, and 20+ ad networks are blocked before they reach your browser - **Cosmetic filtering** — Hides empty ad containers, sticky banners, and overlay placeholders that survive network blocking -- **Real-time stats** — Live counter of blocked ads in the extension badge and popup -- **Dashboard** — View blocked domains, manage filters, customize behavior +- **Per-page counter** — The badge and popup show how many requests were blocked on the current page +- **Allow list & pause** — Disable blocking on a site, or everywhere for 15 minutes +- **Custom filters** — Block extra domains; invalid lines are reported instead of breaking the list +- **Dashboard** — Manage settings, allowed sites and custom filters - **100% local** — Everything runs in your browser. Zero telemetry, zero external calls - **Lightweight** — No jQuery, no framework, just vanilla JS + CSS. Extension size under 200KB @@ -70,7 +72,7 @@ Browser request → declarativeNetRequest rules (33 patterns) content.js scans DOM hides ad containers via content.css ↓ - badge counter updated + badge shows the page's blocked count ``` **Blocked networks include**: Google (doubleclick, googlesyndication, googleadservices), Amazon (amazon-adsystem), Facebook (facebook.com/tr), Criteo, Taboola, Outbrain, AppNexus, Rubicon, PubMatic, Index Exchange, and more. diff --git a/background.js b/background.js index 2142925..a6a133a 100644 --- a/background.js +++ b/background.js @@ -4,6 +4,8 @@ const DYNAMIC_CUSTOM_START = 10000; const DYNAMIC_ALLOW_START = 20000; const PAUSE_ALARM = 'goablockad-pause-resume'; const BADGE_COLOR = '#00bcd4'; +// Chrome caps "unsafe" dynamic rules (block, redirect…) at 5000. +const MAX_CUSTOM_RULES = 5000; // ───────────────────────────────────────────── // Helpers @@ -12,19 +14,41 @@ function parseCustomFilters(raw) { return (raw || '') .split('\n') .map(l => l.trim()) - .filter(l => l.length > 0 && !l.startsWith('#')); + .filter(l => l.length > 0 && !l.startsWith('#') && !l.startsWith('!')); +} + +// A bare domain or a URL becomes "||host^" (the domain and its subdomains); +// anything already using DNR syntax (|, ^, *, /) is kept as-is. +// Returns null for a line that can't be a valid filter. +function toUrlFilter(line) { + const isUrl = /^[a-z]+:\/\//i.test(line); + if (!isUrl && /[|^*\/]/.test(line)) return /^[\x21-\x7e]+$/.test(line) ? line : null; + try { + // URL() lowercases and punycodes the host, but percent-escapes spaces instead of failing. + const host = new URL(isUrl ? line : 'http://' + line).hostname; + return /^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/.test(host) ? '||' + host + '^' : null; + } catch (_) { + return null; + } } function buildCustomBlockRules(lines) { - return lines.map((domain, i) => ({ - id: DYNAMIC_CUSTOM_START + i, - priority: 1, - action: { type: 'block' }, - condition: { - urlFilter: domain, - resourceTypes: ['script', 'image', 'xmlhttprequest', 'sub_frame', 'stylesheet', 'font', 'media', 'other'] - } - })); + const rules = []; + const rejected = []; + for (const line of lines.slice(0, MAX_CUSTOM_RULES)) { + const urlFilter = toUrlFilter(line); + if (!urlFilter) { rejected.push(line); continue; } + rules.push({ + id: DYNAMIC_CUSTOM_START + rules.length, + priority: 1, + action: { type: 'block' }, + condition: { + urlFilter, + resourceTypes: ['script', 'image', 'xmlhttprequest', 'sub_frame', 'stylesheet', 'font', 'media', 'other'] + } + }); + } + return { rules, rejected }; } function buildAllowRules(domains) { @@ -32,51 +56,53 @@ function buildAllowRules(domains) { id: DYNAMIC_ALLOW_START + i, priority: 100, action: { type: 'allowAllRequests' }, + // Matching the top-level navigation to the domain allows every request + // of the page, sub-frames included. condition: { - initiatorDomains: [domain], - resourceTypes: ['main_frame', 'sub_frame'] + requestDomains: [domain], + resourceTypes: ['main_frame'] } })); } -function extractDomain(url) { - try { - return new URL(url).hostname.replace(/^www\./, ''); - } catch (_) { - return null; - } -} - -function formatBadge(count) { - if (count < 1000) return String(count); - if (count < 10000) return (count / 1000).toFixed(1) + 'k'; - if (count < 1000000) return Math.floor(count / 1000) + 'k'; - return (count / 1000000).toFixed(1) + 'M'; -} - +// updateDynamicRules is all-or-nothing: one invalid rule would drop the whole +// batch. Chrome names the faulty rule id, so drop it and retry. async function replaceDynamicRulesInRange(newRules, idStart, idEnd) { - try { - const existing = await chrome.declarativeNetRequest.getDynamicRules(); - const removeRuleIds = existing - .filter(r => r.id >= idStart && r.id <= idEnd) - .map(r => r.id); - await chrome.declarativeNetRequest.updateDynamicRules({ - removeRuleIds, - addRules: newRules - }); - return { ok: true, count: newRules.length }; - } catch (err) { - console.error('GoaBlockAD: updateDynamicRules failed', err); - return { ok: false, error: err.message }; + let rules = newRules; + const rejected = []; + const existing = await chrome.declarativeNetRequest.getDynamicRules(); + const removeRuleIds = existing + .filter(r => r.id >= idStart && r.id <= idEnd) + .map(r => r.id); + for (;;) { + try { + await chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds, addRules: rules }); + return { ok: true, count: rules.length, rejected }; + } catch (err) { + const badId = Number(/\bid (\d+)/.exec(err.message)?.[1]); + const bad = rules.find(r => r.id === badId); + if (!bad) { + console.error('GoaBlockAD: updateDynamicRules failed', err); + return { ok: false, error: err.message, rejected }; + } + rejected.push(bad.condition.urlFilter); + rules = rules.filter(r => r !== bad); + } } } // ───────────────────────────────────────────── // Custom filters (user-provided domains) // ───────────────────────────────────────────── -async function rebuildCustomRules(raw) { - const rules = buildCustomBlockRules(parseCustomFilters(raw)); - return replaceDynamicRulesInRange(rules, DYNAMIC_CUSTOM_START, DYNAMIC_CUSTOM_START + 9999); +async function rebuildCustomRules() { + const { customFilters = '' } = await chrome.storage.local.get('customFilters'); + const lines = parseCustomFilters(customFilters); + const { rules, rejected } = buildCustomBlockRules(lines); + const active = await isProtectionActive(); + const res = await replaceDynamicRulesInRange(active ? rules : [], DYNAMIC_CUSTOM_START, DYNAMIC_CUSTOM_START + 9999); + res.rejected.unshift(...rejected); + res.truncated = Math.max(0, lines.length - MAX_CUSTOM_RULES); + return res; } // ───────────────────────────────────────────── @@ -90,10 +116,13 @@ async function rebuildAllowRules(whitelist) { // ───────────────────────────────────────────── // Pause mode // ───────────────────────────────────────────── -async function applyProtectionState() { +async function isProtectionActive() { const { enabled = true, pausedUntil = 0 } = await chrome.storage.local.get(['enabled', 'pausedUntil']); - const isPaused = pausedUntil && Date.now() < pausedUntil; - const shouldEnable = enabled && !isPaused; + return enabled && !(pausedUntil && Date.now() < pausedUntil); +} + +async function applyProtectionState() { + const shouldEnable = await isProtectionActive(); try { if (shouldEnable) { @@ -104,6 +133,8 @@ async function applyProtectionState() { } catch (err) { console.warn('GoaBlockAD: ruleset toggle warning', err.message); } + // Custom rules are dynamic and ignore the ruleset toggle: add/remove them too. + await rebuildCustomRules(); updateBadgeStyle(shouldEnable); } @@ -115,16 +146,12 @@ function updateBadgeStyle(active) { } } +// While active, the badge shows Chrome's own per-tab count of blocked requests +// (displayActionCountAsBadgeText); the global text only signals OFF / pause. async function refreshBadgeText() { - const { count = 0, enabled = true, pausedUntil = 0 } = await chrome.storage.local.get(['count', 'enabled', 'pausedUntil']); + const { enabled = true, pausedUntil = 0 } = await chrome.storage.local.get(['enabled', 'pausedUntil']); const isPaused = pausedUntil && Date.now() < pausedUntil; - if (!enabled) { - chrome.action.setBadgeText({ text: 'OFF' }); - } else if (isPaused) { - chrome.action.setBadgeText({ text: '⏸' }); - } else { - chrome.action.setBadgeText({ text: count > 0 ? formatBadge(count) : '' }); - } + chrome.action.setBadgeText({ text: !enabled ? 'OFF' : isPaused ? '⏸' : '' }); } // ───────────────────────────────────────────── @@ -132,21 +159,21 @@ async function refreshBadgeText() { // ───────────────────────────────────────────── chrome.runtime.onInstalled.addListener(async () => { const result = await chrome.storage.local.get([ - 'enabled', 'cosmetic', 'count', 'filterStates', 'customFilters', - 'whitelist', 'pausedUntil', 'domainStats' + 'enabled', 'cosmetic', 'filterStates', 'customFilters', 'whitelist', 'pausedUntil' ]); const defaults = {}; if (result.enabled === undefined) defaults.enabled = true; if (result.cosmetic === undefined) defaults.cosmetic = true; - if (result.count === undefined) defaults.count = 0; if (result.filterStates === undefined) defaults.filterStates = {}; if (result.customFilters === undefined) defaults.customFilters = ''; if (result.whitelist === undefined) defaults.whitelist = []; if (result.pausedUntil === undefined) defaults.pausedUntil = 0; - if (result.domainStats === undefined) defaults.domainStats = {}; if (Object.keys(defaults).length > 0) { await chrome.storage.local.set(defaults); } + // Leftovers of the old counter (≤ 1.3.x), which only ever counted cosmetic hides. + await chrome.storage.local.remove(['count', 'domainStats']); + await chrome.declarativeNetRequest.setExtensionActionOptions({ displayActionCountAsBadgeText: true }); await rebuildAllowRules(result.whitelist || []); await applyProtectionState(); await refreshBadgeText(); @@ -167,10 +194,11 @@ chrome.runtime.onStartup.addListener(async () => { // ───────────────────────────────────────────── chrome.storage.onChanged.addListener(async (changes, namespace) => { if (namespace !== 'local') return; - if (changes.customFilters) await rebuildCustomRules(changes.customFilters.newValue); if (changes.whitelist) await rebuildAllowRules(changes.whitelist.newValue || []); - if (changes.enabled || changes.pausedUntil) await applyProtectionState(); - if (changes.count || changes.enabled || changes.pausedUntil) await refreshBadgeText(); + if (changes.enabled || changes.pausedUntil) { + await applyProtectionState(); + await refreshBadgeText(); + } }); // ───────────────────────────────────────────── @@ -211,13 +239,9 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { await chrome.storage.local.set({ whitelist: next }); return sendResponse({ ok: true, whitelisted: idx < 0, whitelist: next }); } - case 'resetDomainStats': { - await chrome.storage.local.set({ domainStats: {} }); - return sendResponse({ ok: true }); - } - case 'rebuildCustomRules': { - const res = await rebuildCustomRules(msg.raw); - return sendResponse(res); + case 'saveCustomFilters': { + await chrome.storage.local.set({ customFilters: String(msg.raw || '') }); + return sendResponse(await rebuildCustomRules()); } default: return sendResponse({ ok: false, error: 'unknown message' }); @@ -225,35 +249,3 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { })(); return true; }); - -// ───────────────────────────────────────────── -// Live stats (requires declarativeNetRequestFeedback — unpacked builds) -// ───────────────────────────────────────────── -const statsBuffer = { count: 0, domains: {} }; -let flushScheduled = false; - -async function flushStats() { - flushScheduled = false; - if (statsBuffer.count === 0) return; - const delta = statsBuffer.count; - const domains = statsBuffer.domains; - statsBuffer.count = 0; - statsBuffer.domains = {}; - const { count = 0, domainStats = {} } = await chrome.storage.local.get(['count', 'domainStats']); - for (const [d, n] of Object.entries(domains)) { - domainStats[d] = (domainStats[d] || 0) + n; - } - await chrome.storage.local.set({ count: count + delta, domainStats }); -} - -if (chrome.declarativeNetRequest.onRuleMatchedDebug) { - chrome.declarativeNetRequest.onRuleMatchedDebug.addListener((info) => { - statsBuffer.count += 1; - const d = extractDomain(info?.request?.url); - if (d) statsBuffer.domains[d] = (statsBuffer.domains[d] || 0) + 1; - if (!flushScheduled) { - flushScheduled = true; - setTimeout(flushStats, 1500); - } - }); -} diff --git a/content.js b/content.js index fd50f0e..346e9ff 100644 --- a/content.js +++ b/content.js @@ -16,13 +16,22 @@ const adSelectors = [ '.sponsored-content' ]; +const COSMETIC_STYLE_ID = 'goablockad-cosmetic-style'; + +// Same matching as the background allow rules (requestDomains): the domain and its subdomains. +function isWhitelisted(whitelist) { + const host = location.hostname.replace(/^www\./, ''); + return (whitelist || []).some(d => host === d || host.endsWith('.' + d)); +} + function applyCosmeticFiltering() { - // Check if filtering is enabled in storage - chrome.storage.local.get(['enabled', 'cosmetic'], (result) => { - if (result.enabled === false || result.cosmetic === false) return; + chrome.storage.local.get(['enabled', 'cosmetic', 'whitelist', 'pausedUntil'], (result) => { + document.getElementById(COSMETIC_STYLE_ID)?.remove(); + const paused = result.pausedUntil && Date.now() < result.pausedUntil; + if (result.enabled === false || result.cosmetic === false || paused || isWhitelisted(result.whitelist)) return; const style = document.createElement('style'); - style.id = 'goablockad-cosmetic-style'; + style.id = COSMETIC_STYLE_ID; style.textContent = ` ${adSelectors.join(',\n')} { display: none !important; @@ -34,21 +43,7 @@ function applyCosmeticFiltering() { pointer-events: none !important; } `; - - if (!document.getElementById('goablockad-cosmetic-style')) { - document.head.appendChild(style); - // console.log('GoaBlockAD: Cosmetic filtering applied'); - - // Increment counter for potential hides (simplified estimation) - // In a real scenario, we'd check how many elements actually matched. - const hiddenCount = document.querySelectorAll(adSelectors.join(',')).length; - if (hiddenCount > 0) { - chrome.storage.local.get(['count'], (result) => { - const newCount = (result.count || 0) + hiddenCount; - chrome.storage.local.set({ count: newCount }); - }); - } - } + (document.head || document.documentElement).appendChild(style); }); } @@ -57,9 +52,7 @@ applyCosmeticFiltering(); // Re-apply if settings change chrome.storage.onChanged.addListener((changes, namespace) => { - if (namespace === 'local' && (changes.enabled || changes.cosmetic)) { - const style = document.getElementById('goablockad-cosmetic-style'); - if (style) style.remove(); + if (namespace === 'local' && (changes.enabled || changes.cosmetic || changes.whitelist || changes.pausedUntil)) { applyCosmeticFiltering(); } }); diff --git a/dashboard.css b/dashboard.css index 770cf88..30dcd24 100644 --- a/dashboard.css +++ b/dashboard.css @@ -401,19 +401,6 @@ body { box-shadow: var(--elev-1); } -/* ─────────── Stat badge ─────────── */ -.stat-badge { - background: var(--primary-container); - color: var(--on-primary-container); - padding: var(--sp-2) var(--sp-4); - border-radius: var(--shape-full); - font-size: 1rem; - font-weight: 700; - font-variant-numeric: tabular-nums; - min-width: 60px; - text-align: center; -} - /* ─────────── Filter lists tab ─────────── */ .filter-stats-bar { display: flex; @@ -622,8 +609,7 @@ body { color: var(--on-surface); } -.whitelist-empty, -.top-domains-empty { +.whitelist-empty { background: transparent !important; color: var(--on-surface-variant) !important; font-style: italic; @@ -648,58 +634,6 @@ body { background: color-mix(in srgb, var(--error) 15%, transparent); } -.top-domains { - list-style: none; - counter-reset: rank; - padding: 0; - margin: 0 0 var(--sp-4) 0; - display: flex; - flex-direction: column; - gap: var(--sp-2); -} - -.top-domains li { - counter-increment: rank; - display: flex; - align-items: center; - gap: var(--sp-3); - padding: var(--sp-3) var(--sp-4); - background: var(--surface-container-low); - border-radius: var(--shape-md); - font-size: 0.9rem; -} - -.top-domains li::before { - content: counter(rank); - color: var(--primary); - font-weight: 700; - min-width: 1.5em; - text-align: right; - font-variant-numeric: tabular-nums; -} - -.top-domains .dom-bar { - flex: 1; - height: 6px; - background: var(--surface-container-highest); - border-radius: var(--shape-full); - overflow: hidden; -} - -.top-domains .dom-bar > span { - display: block; - height: 100%; - background: linear-gradient(90deg, var(--primary), var(--primary-container)); -} - -.top-domains .dom-count { - min-width: 4em; - text-align: right; - color: var(--on-surface-variant); - font-variant-numeric: tabular-nums; - font-weight: 500; -} - /* ─────────── About card ─────────── */ .about-card { text-align: center; diff --git a/dashboard.html b/dashboard.html index 78b499b..fd3ca58 100644 --- a/dashboard.html +++ b/dashboard.html @@ -19,7 +19,7 @@

GoaBlockAD