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
67 changes: 67 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: CI

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
build-and-test:
name: Lint, test, build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Type-check
run: npm run lint:types

- name: ESLint
run: npm run lint:style

- name: Unit + build-integrity tests
run: npm test

- name: Build both extensions
run: npm run extension:build

- name: Validate Firefox build
run: npm run test:firefox:lint

- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: extensions
path: build/
retention-days: 7

smoke:
name: Chromium smoke test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium

- name: Run smoke test
run: xvfb-run --auto-servernum npm run test:smoke:chromium
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ test-results/
coverage/
.DS_Store
*.log

# Local-only AI/agent working notes (never published)
CLAUDE.md
claude-audit.md
137 changes: 102 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,60 +1,127 @@
<div align="center">

<img src="TwitchShield/assets/icons/icon128.png" alt="TwitchShield" width="96" height="96" />

# TwitchShield

TwitchShield is a local-first Chromium and Firefox browser extension that applies configurable request-blocking rules and player recovery behavior on Twitch pages. It has no backend, analytics, telemetry upload, or remote executable code.
#### Block Twitch ads — locally, privately, in Chrome & Firefox.

Twitch ad-block effectiveness has **not been verified with real Twitch playback**. The current automated coverage verifies unit simulations, package integrity, Firefox manifest validation, and Chromium popup/options loading.
[![CI](https://github.com/wpggLabs/TwitchShield/actions/workflows/ci.yml/badge.svg)](https://github.com/wpggLabs/TwitchShield/actions/workflows/ci.yml)
[![Website](https://img.shields.io/badge/site-live-3ecf8e?style=flat-square)](https://wpgglabs.github.io/TwitchShield/)
[![License: MIT](https://img.shields.io/badge/License-MIT-9146ff?style=flat-square)](TwitchShield/LICENSE)
[![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white&style=flat-square)](https://www.typescriptlang.org/)
[![Manifest V3](https://img.shields.io/badge/Manifest-V3-1a73e8?style=flat-square)](https://developer.chrome.com/docs/extensions/mv3/intro/)
[![Local-first](https://img.shields.io/badge/Local--first-✓-22A06B?style=flat-square)](#privacy)

## Requirements
<img src="TwitchShield/assets/store/promo-tile.svg" alt="TwitchShield — block Twitch ads, privately" width="520" />

- Node.js 20 or newer
- npm
<br /><br />

## Install and verify
<sub>
<a href="#overview">Overview</a> ·
<a href="#features">Features</a> ·
<a href="#install">Install</a> ·
<a href="#how-it-works">How it works</a> ·
<a href="docs/SUBMISSION.md">Store submission</a> ·
<a href="#keeping-it-working">Maintaining</a>
</sub>

```powershell
npm install
npm run lint
npm run test:unit
npm run test:integrity
```
</div>

---

## Overview

Twitch inserts ads directly into the video stream (server-side ad insertion, "SSAI"),
so the classic "block the ad request" approach does not work. TwitchShield runs inside
the Twitch player's media worker, inspects each HLS playlist, and transparently swaps
ad-stitched segments for an ad-free stream requested with a player type that Twitch does
not serve mid-rolls to.

Everything happens on your device. The extension makes no network calls except to
Twitch's own APIs, stores no personal data, and ships no analytics or remote code.

> **Honest expectations.** No Twitch ad blocker is perfect. Twitch changes its ad
> delivery frequently, so any blocker will occasionally let an ad through until it is
> updated. TwitchShield blocks the large majority of ads and is built to be easy to
> patch. See [Limitations](#limitations).

## Features

## Build
- **Stream-level ad removal** via HLS playlist interception — the technique that works against SSAI.
- **Per-channel whitelist** — support your favorite streamers by letting their ads play.
- **Blocked-ad counter** and an optional, opt-in on-device diagnostic log.
- **Mute-through-ad** option that silences the player while the clean stream loads.
- **Chrome + Firefox**, both on Manifest V3, from a single codebase.
- **100% local** — no backend, no telemetry, no account access.

```powershell
## Install

### From source

```bash
npm install
npm run extension:build
```

Generated unpacked extensions:
Then load the unpacked build:

- **Chrome / Edge** — open `chrome://extensions`, enable *Developer mode*, click **Load unpacked**, select `build/chromium`.
- **Firefox** — open `about:debugging#/runtime/this-firefox`, click **Load Temporary Add-on**, select `build/firefox/manifest.json`.

### Packaged archives

- Chromium: `build/chromium/`
- Firefox: `build/firefox/`
```bash
npm run pack:chromium # -> artifacts/twitchshield-chromium.zip
npm run pack:firefox # -> artifacts/twitchshield-firefox.zip
```

## How it works

Load `build/chromium/` through `chrome://extensions` with Developer mode enabled. Load `build/firefox/manifest.json` through `about:debugging#/runtime/this-firefox` as a temporary add-on.
| Layer | Responsibility |
| --- | --- |
| `adblock.ts` (MAIN world) | Wraps `window.Worker` so the ad-block payload is injected into the Twitch player's HLS worker before it starts. |
| `adblock-worker.ts` | Runs inside the worker: detects stitched ad markers in each `.m3u8` playlist and substitutes an ad-free playlist. |
| `adblock-config.ts` | The single patchable ruleset — client id, ad markers, player types, token hash. |
| `content.ts` | Injects the engine at `document_start`, applies the whitelist/on-off state, records blocked ads as diagnostics. |
| `packages/core` | Shared, framework-free logic (settings, storage, privacy rules) used by both browser targets. |

## Package
## Development

```powershell
npm run pack:chromium
npm run pack:firefox
```bash
npm run lint # TypeScript strict type-check + ESLint
npm test # unit (privacy) + build-integrity tests
npm run extension:build # build Chromium + Firefox into build/
npm run test:smoke:chromium # load the extension and open popup/options
npm run test:firefox:lint # validate the Firefox build with web-ext
```

Archives are written to `artifacts/`. Each archive contains `manifest.json` at its root.
CI runs the full suite on every push and pull request.

## Keeping it working

## Tests
When Twitch changes its ad delivery, the fix is almost always a one-file edit to
[`TwitchShield/packages/core/src/adblock-config.ts`](TwitchShield/packages/core/src/adblock-config.ts) —
the client id, ad markers, player types, and access-token hash all live there behind a
`signatureVersion`. No logic changes, no remote config fetch. Bump the version, rebuild,
and ship a release. To verify against a live channel:

```powershell
npm test
npm run test:smoke:chromium
npm run test:firefox:lint
```bash
RUN_LIVE=1 TWITCH_CHANNEL=<channel> npm run test:live:adblock
```

- `test:unit`: local player-state simulations only; no browser or Twitch traffic.
- `test:integrity`: builds both targets and verifies every manifest-referenced local asset.
- `test:smoke:chromium`: loads the unpacked Chromium extension and opens its popup and options pages.
- `test:firefox:lint`: validates the generated Firefox extension with Mozilla `web-ext`.
## Limitations

- Effectiveness depends on Twitch's current ad delivery; expect occasional breakage until updated.
- Brief buffering can occur at ad boundaries while the clean stream is fetched.
- Live-ad effectiveness is not asserted in CI (non-deterministic); spot-check before releasing.

## Privacy

No automated test currently verifies live Twitch playback or ad blocking.
- No cookies, tokens, headers, full URLs, account data, chat, or page snapshots are ever stored.
- Diagnostics are **off by default**, capped locally, and never leave your device.
- Host permissions are limited to `*://*.twitch.tv/*`.

## Permissions
## License

- `activeTab`: used only when the popup's whitelist action reads the active tab URL to identify the current Twitch channel. It does not provide persistent browsing access.
[MIT](TwitchShield/LICENSE) · © wpggLabs. Not affiliated with or endorsed by Twitch Interactive, Inc.
72 changes: 72 additions & 0 deletions TwitchShield/apps/chromium-extension/adblock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { AD_BLOCK_CONFIG, adBlockWorkerMain } from "../../packages/core/src/adblock-worker.js";

/**
* MAIN-world entry point.
*
* Runs in the page's own JavaScript context (not the isolated content-script world)
* so it can replace the global `Worker` constructor before the Twitch player spins up
* its HLS worker. Every worker Twitch creates is wrapped with our fetch hook, which
* detects and removes server-side-inserted ads from the media playlist.
*/

declare const __BROWSER_TARGET__: string;

const FLAG = "__twitchShieldActive__";
const globalScope = self as unknown as Record<string, unknown>;

function buildWorkerBlob(originalUrl: string): string {
const constantsLiteral = JSON.stringify(AD_BLOCK_CONFIG);
const hookSource = adBlockWorkerMain.toString();
const payload = [
`const __twitchShieldConstants = ${constantsLiteral};`,
`(${hookSource})(__twitchShieldConstants);`,
`importScripts(${JSON.stringify(originalUrl)});`
].join("\n");
return URL.createObjectURL(new Blob([payload], { type: "application/javascript" }));
}

function installWorkerHook(): void {
if (globalScope[FLAG]) return;
globalScope[FLAG] = true;

const NativeWorker = self.Worker;

const blockingEnabled = (): boolean =>
document.documentElement.getAttribute("data-twitchshield-block") !== "off";

class ShieldedWorker extends NativeWorker {
constructor(scriptURL: string | URL, options?: WorkerOptions) {
const url = scriptURL.toString();
const isTwitchWorker = url.includes("twitch") || url.startsWith("blob:");
if (isTwitchWorker && !url.includes("twitchShield") && blockingEnabled()) {
try {
super(buildWorkerBlob(url), options);
Comment on lines +41 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor pause and whitelist in existing workers

The gate is evaluated only before wrapping a Worker. After a Twitch HLS worker has been constructed, syncSettings only changes data-twitchshield-block in the page; the injected fetch hook inside that already-running worker never reads that attribute or receives the setting. If the user clicks Pause or whitelists the current channel while watching, the existing worker keeps substituting playlists until reload, so ads are still removed even though the UI says they're allowed.

Useful? React with 👍 / 👎.

this.addEventListener("message", (event: MessageEvent) => {
const data = event.data as { twitchShield?: boolean; type?: string; channel?: string } | null;
if (data?.twitchShield) {
window.dispatchEvent(new CustomEvent("twitchshield:ad", { detail: data }));
}
});
return;
} catch {
// Fall through to a native worker if blob construction is blocked.
}
}
super(scriptURL, options);
}
}

Object.defineProperty(self, "Worker", {
configurable: true,
writable: true,
value: ShieldedWorker
});
}

function boot(): void {
if (typeof self.Worker !== "function") return;
installWorkerHook();
void __BROWSER_TARGET__;
}

boot();
Loading
Loading