Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
* text=auto eol=lf
*.png binary
*.gif binary
# Upstream snapshots are kept byte-exact so their recorded sha256 stays valid.
filters/upstream/*.txt -text linguist-vendored
96 changes: 44 additions & 52 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -1,52 +1,44 @@
# SilentBlock — AI Coding Instructions

## Project Overview
SilentBlock is a free, cross-browser (Chrome, Edge, Firefox, Opera) ad-blocking extension built on Manifest V3.

## Architecture
- **Manifest V3** with `declarativeNetRequest` for efficient network-level blocking
- **Cosmetic CSS filtering** to hide ad containers the network layer can't catch
- **Content script** with MutationObserver for dynamic ad removal, popup blocking, and anti-adblock countermeasures
- **Background service worker** handling global/per-site toggle state
- **Cross-browser API alias**: `const api = typeof browser !== 'undefined' ? browser : chrome;` — always use `api.*`, never `chrome.*` directly

## Key Files
| File | Purpose |
|---|---|
| `manifest.json` | Extension manifest — permissions, content scripts, declarativeNetRequest rules |
| `background.js` | Service worker — toggle logic, badge updates, dynamic rule management |
| `rules/rules.json` | Static blocking rules (domain-based and URL-pattern-based) |
| `content/cosmetic.css` | CSS selectors that hide known ad elements |
| `content/content.js` | Dynamic ad hiding, popup blocker, anti-adblock bait |
| `popup/popup.html` | Extension popup UI |
| `popup/popup.css` | Popup styles (dark theme) |
| `popup/popup.js` | Popup logic — reads/writes toggle state via messages to background.js |

## Conventions
- All JS files use `'use strict'` at the top
- Use `api.*` instead of `chrome.*` or `browser.*` for cross-browser compatibility
- Rule IDs in `rules.json` must be unique integers starting from 1
- CSS selectors in `cosmetic.css` use `!important` to override inline styles
- Version is tracked in `manifest.json` → `"version"` field — use the bump script to increment

## Version Bumping
Run `version-bump.ps1` with `-Part patch|minor|major` to bump the version in `manifest.json`.
The script updates the version, commits, and creates a git tag.

## Adding Blocking Rules
1. Add new entries to `rules/rules.json` with a unique `id` (increment from last)
2. Use `requestDomains` for domain-based blocking, `urlFilter` for URL patterns
3. Always specify `resourceTypes` array
4. After editing, reload the extension in the browser

## Browser Support
- Chrome 88+ (MV3 base support)
- Edge 88+ (Chromium-based, same as Chrome)
- Firefox 128+ (MV3 + declarativeNetRequest)
- Opera 74+ (Chromium-based)

## Do NOT
- Add subscription/payment/nag features — this is free forever
- Add telemetry, analytics, or any data collection
- Add remote rule fetching — all rules are local
- Use `chrome.*` directly — always use the `api` alias
# SilentBlock: notes for AI coding assistants

SilentBlock is a Chrome MV3 ad blocker. Filter lists are compiled at build time into
declarativeNetRequest rulesets, cosmetic data files and MAIN-world scriptlet buckets.
Read README.md first; this file is the short list of things that bite.

## Rules
- Never fetch filters or code at runtime. Lists are committed snapshots in `filters/upstream/`.
- No telemetry, analytics or remote calls of any kind. Keep it free, with no nags or upsells.
- On ordinary sites SilentBlock must not patch page APIs. Anything that runs in the page's
MAIN world goes through a scriptlet that a filter targets at specific hosts.
- Hide, never remove, page elements in anything heuristic (`src/shared/unwall-core.js`).
- Use `chrome.*` promise APIs (Firefox supports them too). No `browser` alias, no callbacks.
- Build output lives in `dist/`. Never edit it by hand, and never commit it.

## Traps
- A single `regexFilter` Chrome rejects stops the ENTIRE extension loading, and
`--load-extension` fails silently. Run the full build (it validates regexes with
`declarativeNetRequest.isRegexSupported`) and `node scripts/verify-load.mjs`, which
prints Chrome's real error.
- One invalid selector voids a whole grouped CSS rule; the build validates selectors in Chromium.
- `||host^` means "host and subdomains" (becomes `requestDomains`); `||host` without `^`
does not, and must stay a `urlFilter`.
- Entity domains (`example.*`) cannot be expressed; filters using only entities are
dropped, never widened to generic.
- Priorities: block 10, redirect 11, list exception 20, `$important` 30, user pause 1000,
security block 2000 (beats a pause), security "continue anyway" session rule 3000.
- The service worker can die at any time. State lives in `chrome.storage`; `apply.js`
must stay idempotent. All listeners are registered synchronously at top level.
- Settings changes must come from extension pages (`sender.url` is ours). Content scripts
may only send `tokens` and `wall`, neither of which changes settings.
- Chrome writes `_metadata/` into a loaded unpacked folder; `package.mjs` skips `_` paths.
- Anything that must happen before a tab-under's redirect (popup opener registration, the
last committed URL) is done synchronously at the top of the listener, before any await.
- Security page URLs (~36k) live in `security/urls.json` and are checked by the worker;
as DNR rules they would blow the 30k guarantee.
- Icons are generated: edit `scripts/icons.mjs` and run it, never hand-edit the PNGs.

## Tests
- `npm test` for the compiler and settings; `npm run test:e2e` for the real build in Chromium.
- `filters/silentblock-selftest.txt` targets reserved `.test` hosts and drives the e2e tests.
- Headless Chromium maps every host to the local fixture server
(`--host-resolver-rules`). Use `.test` hosts in fixtures; real Google hosts are
HSTS-preloaded and will not load over http.
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Build (validates selectors and regexes in Chromium)
run: npm run build
- run: npm test
- name: Chrome accepts the build
run: node scripts/verify-load.mjs chrome
- run: npm run test:e2e
- run: npm run package
- uses: actions/upload-artifact@v4
with:
name: silentblock-${{ github.sha }}
path: |
release/*.zip
dist/build-report.json
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-traces
path: test-results/
42 changes: 42 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Release

on:
push:
tags: ['v*']

permissions:
contents: write

jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Tag matches package.json
run: test "v$(node -p 'require("./package.json").version')" = "$GITHUB_REF_NAME"
- run: npm run build
- run: npm test
- run: node scripts/verify-load.mjs chrome
- run: npm run test:e2e
- run: npm run package
- name: Release notes from CHANGELOG
run: |
node -e '
const v = process.env.GITHUB_REF_NAME.slice(1);
const cl = require("fs").readFileSync("CHANGELOG.md", "utf8");
const start = cl.indexOf(`## [${v}]`);
if (start === -1) { console.error(`No CHANGELOG entry for ${v}`); process.exit(1); }
const next = cl.indexOf("\n## [", start + 1);
require("fs").writeFileSync("notes.md", cl.slice(cl.indexOf("\n", start) + 1, next === -1 ? undefined : next).trim() + "\n");
'
- name: Publish
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "$GITHUB_REF_NAME" release/*.zip --title "SilentBlock ${GITHUB_REF_NAME#v}" --notes-file notes.md
48 changes: 48 additions & 0 deletions .github/workflows/update-lists.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Update filter lists

# Weekly refresh of filters/upstream/. Opens a PR only if the build and every test pass
# with the new lists, so a bad upstream change never lands silently.
on:
schedule:
- cron: '0 20 * * 0' # Sunday 20:00 UTC = Monday morning in Sydney
workflow_dispatch:

permissions:
contents: write
pull-requests: write

jobs:
update:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run lists:update
- name: Anything changed?
id: diff
run: |
if git diff --quiet -- filters/upstream; then echo "changed=false" >> "$GITHUB_OUTPUT"; else echo "changed=true" >> "$GITHUB_OUTPUT"; fi
- if: steps.diff.outputs.changed == 'true'
run: npm run build
- if: steps.diff.outputs.changed == 'true'
run: npm test && node scripts/verify-load.mjs chrome && npm run test:e2e
- if: steps.diff.outputs.changed == 'true'
name: Open PR
env:
GH_TOKEN: ${{ github.token }}
run: |
branch="lists/$(date -u +%Y-%m-%d)"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "$branch"
git add filters/upstream
git commit -m "Update filter lists $(date -u +%Y-%m-%d)"
git push -u origin "$branch"
node -e 'const r=require("./dist/build-report.json").meta.rulesets; console.log("| ruleset | rules | regex | domains |\n|---|---|---|---|\n"+Object.entries(r).map(([k,v])=>`| ${k} | ${v.rules} | ${v.regex} | ${v.domains} |`).join("\n"))' > body.md
gh pr create --title "Update filter lists $(date -u +%Y-%m-%d)" --body-file body.md
11 changes: 9 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,18 @@ Desktop.ini
!.vscode/settings.json
*.code-workspace

# Build / packaging
# Dependencies
node_modules/

# Build output
dist/
release/
test-results/
playwright-report/
*.zip
*.xpi
*.crx
archive/
*.pem

# Chrome extension metadata (generated when loaded unpacked)
_metadata/
Expand Down
76 changes: 76 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Changelog

## [Unreleased]

## [2.0.0] - 2026-09-28

A rebuild. The 1.x engine (a hand-written list of ~226 domains plus global page patches)
is replaced by the real filter lists compiled into Chrome's native blocker.

### Added
- EasyList, EasyPrivacy, uBlock filters (main, quick fixes, unbreak, privacy, cookie
notices) and EasyList Cookie List, compiled at build time to ~20,700 DNR rules. Nothing
is fetched at runtime.
- Generic cosmetic filtering that injects only the selectors matching the page's own class
and id names, plus site-specific hiding and `:style()` rules for ~28,000 hostnames. All as
user-origin CSS that pages cannot see or override.
- 33 uBO-compatible scriptlets running site-specific anti-adblock fixes in the page before
its scripts (~8,200 filters).
- Redirect stubs for GPT, AdSense, analytics.js, ga.js, gtag/GTM, apstag, comScore and
noop files, so pages that wait on ad or analytics callbacks keep working.
- Popup and tab-under blocking from the lists' 3,000 `$popup` filters, which DNR cannot
express: new tabs a page opens are followed for their first hops and closed if they land
on an ad host; a tab sent to an ad right after opening itself in a new tab is sent back
and the copy closed. Tabs opened from webmail and ad consoles are never closed.
- Malware and scam protection (new "Malware & scams" category): uBO Badware, URLhaus,
the Phishing URL Blocklist and DurableNapkin's scam list. ~46,000 hosts are blocked by
DNR above list exceptions and above a paused site; ~36,000 page URLs (too many for DNR)
are checked on each navigation. Listed pages show a warning page with "Back to safety"
and "Continue anyway" (allowed until the browser closes). No API keys, no lookups.
- Toolbar icon shows the page's status: slate blue (nothing to block), green (ads and
trackers blocked), amber (pop-up, tab-under or nag wall dealt with), red (malware or
scam host blocked), grey (paused or off). Counts are live, from read-only request
watching. Can be turned off in settings.
- New icon: the Tidewater shield, with the finger cut through so the toolbar shows through it.
- Popup: per-page blocked-request count, "Kill nag wall", "Always on this site", reload
prompt, clear paused state (including when a parent domain is paused).
- Options page: filter categories, paused and nag-wall site lists, badge count, settings
export and import, list provenance and licences.
- Exceptions so Google Ads, GA4, GTM and Tag Assistant, Meta, LinkedIn and Microsoft
consoles, and tracker vendors' own dashboards work.
- Build validates every selector and regex in real Chromium (one rejected regex stops
Chrome loading the extension at all), and fails past 30,000 static rules.
- Unit tests, and Playwright end-to-end tests against the real build.
- Firefox build (`dist/firefox`), lint-clean but untested.

### Fixed
- Settings were wiped on every extension update and every Chrome update. Paused sites now
persist, and 1.x settings (`disabledSites`) are migrated.
- Any page could switch SilentBlock off by dispatching the `__sb_disable` event. Paused
sites are now excluded when scripts are registered, so there is no switch to find.
- Per-site pause left the cosmetic CSS running. Pausing now removes every layer.
- The update check never ran (its alarm was recreated, and so reset, on every worker wake).
The checker is gone: the repo is private and updates come through releases.
- The anti-adblock bait was hidden by SilentBlock's own CSS, which advertised the blocker.
Generic bait is gone; detection is handled by site-specific scriptlets.
- Ad counter was overwritten by whichever iframe reported last and counted its own bait.
It now counts network blocks for the current page.
- Facebook sponsored-post hiding re-scanned every span with a layout read on every
mutation, every 2 seconds and every scroll. It now judges each new post once.

### Changed
- Network rules only apply to third parties unless the list says otherwise, so vendors'
own sites keep working.
- No page API is patched on ordinary sites: `fetch`, XHR, `sendBeacon`, `Image`,
`window.open`, `classList.add` and `style.filter` stay native. 1.x blocked all blur,
forced `overflow:auto` on every page and deleted any overlay mentioning "whitelist".
- Dropped rules that broke real functionality: Chargebee (checkout), Piano, TinyPass,
Pelcro and Poool (subscriber logins), AMP, the IMA SDK, Branch deep links, consent
platforms, and error monitoring.
- Permissions: removed `tabs`, `notifications`, `alarms`, `activeTab` and
`declarativeNetRequestFeedback`; added `scripting`, `webNavigation` and `webRequest`
(read-only, for live counts).
- Repository moved to `ClickClickMedia/SilentBlock`.

## [1.6.0] - 2026-03-30
Last release of the 1.x line. See the git history for earlier versions.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@.github/copilot-instructions.md
Loading
Loading