diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8a64526 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index abf09ac..332c7eb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ test-results/ coverage/ .DS_Store *.log + +# Local-only AI/agent working notes (never published) +CLAUDE.md +claude-audit.md diff --git a/README.md b/README.md index 6ac0ec4..ed09dec 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,127 @@ +
+ +TwitchShield + # 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 +TwitchShield — block Twitch ads, privately -- Node.js 20 or newer -- npm +

-## Install and verify + + Overview · + Features · + Install · + How it works · + Store submission · + Maintaining + -```powershell -npm install -npm run lint -npm run test:unit -npm run test:integrity -``` +
+ +--- + +## 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= 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. diff --git a/TwitchShield/apps/chromium-extension/adblock.ts b/TwitchShield/apps/chromium-extension/adblock.ts new file mode 100644 index 0000000..214eb07 --- /dev/null +++ b/TwitchShield/apps/chromium-extension/adblock.ts @@ -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; + +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); + 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(); diff --git a/TwitchShield/apps/chromium-extension/background.ts b/TwitchShield/apps/chromium-extension/background.ts index 0c79e89..3d58c4b 100644 --- a/TwitchShield/apps/chromium-extension/background.ts +++ b/TwitchShield/apps/chromium-extension/background.ts @@ -1,86 +1,20 @@ import { TwitchShieldStorage } from "../../packages/core/src/storage.js"; -import { DEFAULT_SETTINGS, ProtectionMode } from "../../packages/core/src/types.js"; -import { getDeclarativeRules } from "../../packages/rules/src/dynamic-rules.js"; -/** - * TwitchShield - Background Service Worker - */ - -// Initialize extension state and declarative rules -chrome.runtime.onInstalled.addListener(async (details) => { - console.log("TwitchShield installed/updated:", details.reason); - - const settings = await TwitchShieldStorage.getSettings(); - if (!settings) { - await TwitchShieldStorage.saveSettings(DEFAULT_SETTINGS); - } - - await updateDeclarativeRules(); - await TwitchShieldStorage.appendDiagnostic( - "System", - "recovery_action", - `TwitchShield initialized in ${settings?.mode || ProtectionMode.BALANCED} mode.` - ); -}); - -// Watch for messaging from Content Script & Popup UI -chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { +chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { if (request.action === "updateSettings") { - TwitchShieldStorage.saveSettings(request.settings) - .then(() => updateDeclarativeRules()) - .then(() => { - sendResponse({ success: true }); - // Notify any active Twitch tabs of settings update - chrome.tabs.query({ url: "*://*.twitch.tv/*" }, (tabs) => { - tabs.forEach((tab) => { - if (tab.id) { - chrome.tabs.sendMessage(tab.id, { action: "syncSettings", settings: request.settings }); - } - }); - }); - }); - return true; // Keep message channel open for async response + void TwitchShieldStorage.saveSettings(request.settings).then(async () => { + const tabs = await chrome.tabs.query({ url: "*://*.twitch.tv/*" }); + await Promise.all(tabs.filter((tab) => tab.id).map((tab) => chrome.tabs.sendMessage(tab.id!, { + action: "syncSettings", + settings: request.settings + }).catch(() => undefined))); + sendResponse({ success: true }); + }); + return true; } - if (request.action === "logEvent") { - const { channel, eventType, details, duration } = request; - TwitchShieldStorage.appendDiagnostic(channel, eventType, details, duration) - .then((log) => { - sendResponse({ success: true, log }); - }); + if (request.action === "recordDiagnostic") { + void TwitchShieldStorage.appendDiagnostic(request.event).then((event) => sendResponse({ success: true, event })); return true; } }); - -/** - * Configure declarative net request rules based on current protection levels - */ -async function updateDeclarativeRules() { - const settings = await TwitchShieldStorage.getSettings(); - const rules = getDeclarativeRules(); - - const ruleIdsToRemove = rules.map((r) => r.id); - - if (settings.enabled && settings.mode !== ProtectionMode.COMPATIBILITY) { - // Convert rules to declarative net request schema - const activeRules: chrome.declarativeNetRequest.Rule[] = rules.map((r) => ({ - id: r.id, - priority: r.priority, - action: { type: "block" as chrome.declarativeNetRequest.RuleActionType }, - condition: { - urlFilter: r.urlFilter, - resourceTypes: r.resourceTypes as any - } - })); - - await chrome.declarativeNetRequest.updateDynamicRules({ - removeRuleIds: ruleIdsToRemove, - addRules: activeRules - }); - } else { - // If disabled or in compatibility mode, bypass blocking rules - await chrome.declarativeNetRequest.updateDynamicRules({ - removeRuleIds: ruleIdsToRemove - }); - } -} diff --git a/TwitchShield/apps/chromium-extension/content.ts b/TwitchShield/apps/chromium-extension/content.ts index 99443ea..662dbc1 100644 --- a/TwitchShield/apps/chromium-extension/content.ts +++ b/TwitchShield/apps/chromium-extension/content.ts @@ -1,162 +1,166 @@ import { TwitchShieldStorage } from "../../packages/core/src/storage.js"; -import { ExtensionSettings, DEFAULT_SETTINGS } from "../../packages/core/src/types.js"; - -/** - * TwitchShield - Content Script - */ - -let settings: ExtensionSettings = DEFAULT_SETTINGS; -let activeChannel = "unknown"; -let isAdActive = false; -let originalVolume = 0.5; +import { isObservationDisabled } from "../../packages/core/src/observation.js"; +import { + BrowserTarget, + DiagnosticEventInput, + DiagnosticEventType, + ExtensionSettings, + PageCategory +} from "../../packages/core/src/types.js"; + +declare const __BROWSER_TARGET__: BrowserTarget; + +let settings: ExtensionSettings; +let pageCategory: PageCategory = "unknown"; +let channel: string | undefined; +let observedVideo: HTMLVideoElement | null = null; +let stalledAt: number | null = null; +let playerFound = false; +let adSignalPresent = false; +let mutationTimer: number | undefined; +let adsBlocked = 0; + +function injectAdBlockEngine(): void { + try { + const script = document.createElement("script"); + script.src = chrome.runtime.getURL("adblock.js"); + script.type = "text/javascript"; + script.dataset.twitchshield = "engine"; + (document.head || document.documentElement).appendChild(script); + script.addEventListener("load", () => script.remove()); + } catch { + // Injection is best-effort; observation still functions without it. + } +} -// Load settings locally at start -async function initContentScript() { - settings = await TwitchShieldStorage.getSettings(); - resolveChannelName(); - setupObserver(); - setupRecoveryLoop(); - injectMainWorldIntercept(); - - // Log local diagnostic event - sendLog("method_attempted", "TwitchShield content monitors injected successfully on Twitch player page context."); +function applyBlockingState(): void { + const disabled = isObservationDisabled(settings, channel); + document.documentElement.setAttribute("data-twitchshield-block", disabled ? "off" : "on"); } -// Extract channel name from URL (e.g. www.twitch.tv/ninja -> ninja) -function resolveChannelName() { - const path = window.location.pathname; - const parts = path.split("/").filter((p) => p.length > 0); - if (parts.length > 0 && !["directory", "videos", "u", "search", "moderator", "popout"].includes(parts[0])) { - activeChannel = parts[0].toLowerCase(); - } +function derivePageContext(): void { + const parts = location.pathname.split("/").filter(Boolean); + pageCategory = location.hostname === "clips.twitch.tv" + ? "clip" + : parts[0] === "videos" || parts[1] === "videos" + ? "VOD" + : parts.length === 1 + ? "live" + : "unknown"; + channel = pageCategory === "live" && /^[a-z0-9_]{1,25}$/i.test(parts[0] || "") + ? parts[0].toLowerCase() + : pageCategory === "VOD" && parts[1] === "videos" && /^[a-z0-9_]{1,25}$/i.test(parts[0]) + ? parts[0].toLowerCase() + : undefined; } -// Send event metrics to background for diagnostic storage -function sendLog(eventType: string, details: string, duration?: number) { - if (!settings.enabled || !settings.diagnosticMode) return; - - chrome.runtime.sendMessage({ - action: "logEvent", - channel: activeChannel, +function record(eventType: DiagnosticEventType, status: string, duration?: number): void { + if (!settings.diagnosticMode) return; + const event: DiagnosticEventInput = { eventType, - details, + browserTarget: __BROWSER_TARGET__, + pageCategory, + channel, + status, duration - }, () => { - // Ignore error if background is pending - if (chrome.runtime.lastError) { - // Background context asleep or non-responsive, fallback silently - } - }); + }; + chrome.runtime.sendMessage({ action: "recordDiagnostic", event }).catch(() => undefined); } -// Watch for Twitch HTML tree adjustments to identify inserted ad states -function setupObserver() { - const observer = new MutationObserver(() => { - if (!settings.enabled) return; - - // Check if user has whitelisted this channel - if (settings.whitelist.map(c => c.toLowerCase()).includes(activeChannel)) { - return; // Permitted to bypass - } +function observationDisabled(): boolean { + return isObservationDisabled(settings, channel); +} - const videoElement = document.querySelector("video"); - if (!videoElement) return; - - // Twitch marks ads with specific container attributes and overlay banners - const adIndicator = document.querySelector('[data-a-target="video-ad-label"], [data-a-target="player-overlay-advertisement"]'); - - if (adIndicator && !isAdActive) { - // Ad started - isAdActive = true; - originalVolume = videoElement.volume; - sendLog("ad_detected", "Commercial block detected on Twitch stream feed."); - - if (settings.muteDuringAds) { - videoElement.muted = true; - sendLog("method_attempted", "Auto-muted audio stream to prevent commercial noise."); - } - } else if (!adIndicator && isAdActive) { - // Ad ended - isAdActive = false; - if (settings.muteDuringAds && videoElement.muted) { - videoElement.muted = false; - videoElement.volume = originalVolume; - sendLog("success", "Commercial block completed. Restored default volume levels."); - } +function bindVideo(video: HTMLVideoElement): void { + if (video === observedVideo) return; + observedVideo = video; + playerFound = true; + record("player_found", "HTML video player found."); + video.addEventListener("playing", () => { + stalledAt = null; + record("video_playing", "Video entered playing state."); + }); + video.addEventListener("pause", () => record("video_paused", "Video entered paused state.")); + video.addEventListener("stalled", () => { + stalledAt = performance.now(); + record("video_stalled", "Video reported a stalled state."); + record("recovery_candidate_detected", "A stalled player may require user recovery."); + }); + video.addEventListener("error", () => record("playback_error", "Video element reported a playback error.")); + video.addEventListener("canplay", () => { + if (stalledAt !== null) { + record("video_playing", "Video became playable after a stall.", performance.now() - stalledAt); + stalledAt = null; } }); +} - observer.observe(document.documentElement, { - childList: true, - subtree: true - }); +function inspectPage(): void { + if (observationDisabled()) return; + const video = document.querySelector("video"); + if (video instanceof HTMLVideoElement) bindVideo(video); + + const adSignal = Boolean(document.querySelector( + '[data-a-target="video-ad-label"], [data-a-target="video-ad-countdown"], [data-a-target="player-overlay-advertisement"]' + )); + if (adSignal && !adSignalPresent) record("ad_ui_signal_detected", "Known Twitch ad UI marker became visible."); + adSignalPresent = adSignal; + + if (playerFound) { + window.clearTimeout(mutationTimer); + mutationTimer = window.setTimeout(() => record("player_ui_changed", "Player UI structure changed."), 500); + } } -// Monitor for stalled players (e.g. frozen video or 3000 error) -function setupRecoveryLoop() { - setInterval(() => { - if (!settings.enabled || !settings.autoRecoveryEnabled) return; - - const videoElement = document.querySelector("video"); - if (!videoElement) return; - - // If the stream is playing but stalled/buffered for too long, reload player - if (videoElement.readyState < 2 && !videoElement.paused && isAdActive) { - sendLog("playback_stalled", "Twitch stream player stalled during commercial block. Initiating auto-recovery..."); - - // Perform local recovery: reload stream engine by triggering source reload - const prevSrc = videoElement.src; - videoElement.src = ""; - videoElement.load(); - setTimeout(() => { - videoElement.src = prevSrc; - videoElement.play().then(() => { - sendLog("recovery_action", "Stream source reloaded and playback successfully synchronized."); - }).catch(() => { - sendLog("failure", "Failed to force restart stream automatically."); - }); - }, 500); - } +let muteRestoreTimer: number | undefined; + +function muteThroughAd(): void { + if (!settings.muteDuringAds || !observedVideo) return; + const previouslyMuted = observedVideo.muted; + observedVideo.muted = true; + window.clearTimeout(muteRestoreTimer); + muteRestoreTimer = window.setTimeout(() => { + if (observedVideo && !previouslyMuted) observedVideo.muted = false; }, 4000); } -// Injects interception capabilities into the MAIN window execution space -function injectMainWorldIntercept() { - try { - const script = document.createElement("script"); - script.textContent = ` - (function() { - // Intercept native network requests for the Amazon Twitch Player - const originFetch = window.fetch; - window.fetch = async function(...args) { - const url = typeof args[0] === 'string' ? args[0] : args[0].url; - if (url && url.includes('usher.ttvnw.net/api/channel/hls/')) { - // Strip ad tracker queries - const cleanUrl = url.replace(/&ad_support=true/, '&ad_support=false&player_banner=false'); - if (args[0].url) args[0].url = cleanUrl; - else args[0] = cleanUrl; - } - return originFetch.apply(this, args); - }; - })(); - `; - (document.head || document.documentElement).appendChild(script); - script.remove(); - } catch (e) { - console.error("TwitchShield intercept script injection error:", e); +function onAdEvent(event: Event): void { + const detail = (event as CustomEvent).detail as { type?: string } | undefined; + if (!detail) return; + adsBlocked += 1; + muteThroughAd(); + record("ad_ui_signal_detected", `Ad blocked at stream level (${detail.type ?? "unknown"}). Total this session: ${adsBlocked}.`); +} + +async function startObservation(): Promise { + // Install the stream-level ad-block engine as early as possible, before the + // Twitch player worker is created. Gating happens via the control attribute. + injectAdBlockEngine(); + window.addEventListener("twitchshield:ad", onAdEvent); + + settings = await TwitchShieldStorage.getSettings(); + derivePageContext(); + applyBlockingState(); + if (observationDisabled()) { + if (channel) record("extension_disabled_on_channel", "Observation disabled for this channel."); + return; } + record("page_loaded", "Twitch page observation started."); + inspectPage(); + new MutationObserver(inspectPage).observe(document.documentElement, { childList: true, subtree: true }); } -// Message listener from extension action/options popup chrome.runtime.onMessage.addListener((request) => { if (request.action === "syncSettings") { + const wasObserving = settings.enabled && settings.diagnosticMode; settings = request.settings; - resolveChannelName(); + derivePageContext(); + applyBlockingState(); + if (wasObserving && observationDisabled() && channel) { + record("extension_disabled_on_channel", "Observation disabled for this channel."); + } + inspectPage(); } }); -// Run -initContentScript(); -window.addEventListener("navigation", () => { - resolveChannelName(); -}); +void startObservation(); diff --git a/TwitchShield/apps/chromium-extension/manifest.json b/TwitchShield/apps/chromium-extension/manifest.json index 09f5d53..555d7c9 100644 --- a/TwitchShield/apps/chromium-extension/manifest.json +++ b/TwitchShield/apps/chromium-extension/manifest.json @@ -1,10 +1,9 @@ { "manifest_version": 3, - "name": "TwitchShield: Ad Blocker & Safe Stream Recovery", + "name": "TwitchShield — Ad Blocker for Twitch", "version": "1.0.0", - "description": "High-security local stream ad-blocker and playback-recovery tool with zero telemetry and local diagnostic boards.", + "description": "Removes stream-level (SSAI) ads on Twitch locally, with private on-device diagnostics. No backend, no telemetry.", "permissions": [ - "declarativeNetRequest", "activeTab", "storage" ], @@ -35,6 +34,12 @@ "page": "options.html", "open_in_tab": true }, + "web_accessible_resources": [ + { + "resources": ["adblock.js"], + "matches": ["*://*.twitch.tv/*"] + } + ], "icons": { "16": "icons/icon16.png", "48": "icons/icon48.png", diff --git a/TwitchShield/apps/chromium-extension/options.html b/TwitchShield/apps/chromium-extension/options.html index a0ecb0f..95e0d8e 100644 --- a/TwitchShield/apps/chromium-extension/options.html +++ b/TwitchShield/apps/chromium-extension/options.html @@ -199,58 +199,50 @@

- TwitchShield Workspace & Options + TwitchShield Dashboard

-
Complete localized protection controls, sandboxed whitelist, and on-device diagnostic exporter
+
Ad-blocking controls, channel whitelist, and on-device diagnostic export
-

Stream Protection & Engine Tuning

- +

Ad Blocking

+
TwitchShield Status - Enable or pause the overall ad-blocking and intercept routines + Enable or pause stream-level ad blocking on Twitch
- Mute Audio on Ads - Automatically drops tab audio during active commercials -
- -
- -
-
- Auto-Recovery Engine - Automatically reloads individual stream threads if the player crashes with error 3000 during ad checks + Local Diagnostic Mode + Store approved local event fields on this device; disabled by default
- +
- Reduce Quality During Ads - Drops player quality stream down to 360p inside the manifest to limit network congestion during mid-rolls + Mute during ad swap + Briefly mute the player while the ad-free stream loads, to avoid any ad audio
- +

Anonymized Technical Diagnostics Exporter

- Export technical event logs to help troubleshoot. TwitchShield exports only on-device timestamps, channel domains, block triggers, and recovery steps. No personal accounts, login details, or general browsing histories are collected. + Export approved local observation events. No cookies, tokens, headers, full URLs, account data, chat, HTML snapshots, or network payloads are stored.

- +
@@ -259,7 +251,7 @@

Anonymized Technical Diagnostics Exporter

Whitelisted Channels

- Streamers in this list will bypass all telemetry and ad blocking blocks. Perfect for supporting channels you subscribe to! + Ad blocking is disabled for channels in this list, so you can support your favorite streamers by letting their ads play.

diff --git a/TwitchShield/apps/chromium-extension/options.ts b/TwitchShield/apps/chromium-extension/options.ts index 4f6a3ec..079aadf 100644 --- a/TwitchShield/apps/chromium-extension/options.ts +++ b/TwitchShield/apps/chromium-extension/options.ts @@ -1,183 +1,126 @@ import { TwitchShieldStorage } from "../../packages/core/src/storage.js"; -import { DEFAULT_SETTINGS, ExtensionSettings } from "../../packages/core/src/types.js"; +import { BrowserTarget, DEFAULT_SETTINGS, ExtensionSettings } from "../../packages/core/src/types.js"; -/** - * TwitchShield - Options & Diagnostics Dashboard TS - */ +declare const __BROWSER_TARGET__: BrowserTarget; -let settings: ExtensionSettings = DEFAULT_SETTINGS; +let settings: ExtensionSettings = { ...DEFAULT_SETTINGS }; const btnStatusToggle = document.getElementById("btn-status-toggle") as HTMLButtonElement; +const btnDiagnosticsToggle = document.getElementById("btn-diagnostics-toggle") as HTMLButtonElement; const btnMuteToggle = document.getElementById("btn-mute-toggle") as HTMLButtonElement; -const btnRecoverToggle = document.getElementById("btn-recover-toggle") as HTMLButtonElement; -const btnQualityToggle = document.getElementById("btn-quality-toggle") as HTMLButtonElement; - const inputWhitelist = document.getElementById("input-whitelist") as HTMLInputElement; const btnAddWhitelist = document.getElementById("btn-add-whitelist") as HTMLButtonElement; const whitelistBox = document.getElementById("whitelist-box") as HTMLDivElement; - const diagExportArea = document.getElementById("diag-export-area") as HTMLTextAreaElement; const btnRefreshLogs = document.getElementById("btn-refresh-logs") as HTMLButtonElement; const btnDownloadLogs = document.getElementById("btn-download-logs") as HTMLButtonElement; const btnClearLogs = document.getElementById("btn-clear-logs") as HTMLButtonElement; -async function initOptions() { +async function initOptions(): Promise { settings = await TwitchShieldStorage.getSettings(); renderSettings(); renderWhitelist(); await refreshDiagnosticArea(); - // Button Action Bindings btnStatusToggle.addEventListener("click", () => { settings.enabled = !settings.enabled; - saveAndPublish(); + void saveAndPublish(); + }); + btnDiagnosticsToggle.addEventListener("click", () => { + settings.diagnosticMode = !settings.diagnosticMode; + void saveAndPublish(); }); - btnMuteToggle.addEventListener("click", () => { settings.muteDuringAds = !settings.muteDuringAds; - saveAndPublish(); - }); - - btnRecoverToggle.addEventListener("click", () => { - settings.autoRecoveryEnabled = !settings.autoRecoveryEnabled; - saveAndPublish(); - }); - - btnQualityToggle.addEventListener("click", () => { - settings.lowQualityDuringAds = !settings.lowQualityDuringAds; - saveAndPublish(); + void saveAndPublish(); }); - btnAddWhitelist.addEventListener("click", addWhitelistChannel); - inputWhitelist.addEventListener("keypress", (e) => { - if (e.key === "Enter") addWhitelistChannel(); + inputWhitelist.addEventListener("keypress", (event) => { + if (event.key === "Enter") addWhitelistChannel(); }); - - btnRefreshLogs.addEventListener("click", refreshDiagnosticArea); - btnDownloadLogs.addEventListener("click", downloadDiagnosticReport); - btnClearLogs.addEventListener("click", clearLocalCache); + btnRefreshLogs.addEventListener("click", () => void refreshDiagnosticArea()); + btnDownloadLogs.addEventListener("click", () => void downloadDiagnosticReport()); + btnClearLogs.addEventListener("click", () => void clearDiagnostics()); } -function renderSettings() { - // Global shield toggle styling - if (settings.enabled) { - btnStatusToggle.textContent = "SHIELD ACTIVE"; - btnStatusToggle.style.backgroundColor = "var(--accent-green)"; - btnStatusToggle.style.color = "black"; - } else { - btnStatusToggle.textContent = "SHIELD SUSPENDED"; - btnStatusToggle.style.backgroundColor = "var(--bg-surface)"; - btnStatusToggle.style.color = "var(--accent-red)"; - } - - // Sub options buttons - updateToggleStyle(btnMuteToggle, settings.muteDuringAds); - updateToggleStyle(btnRecoverToggle, settings.autoRecoveryEnabled); - updateToggleStyle(btnQualityToggle, settings.lowQualityDuringAds); -} - -function updateToggleStyle(element: HTMLButtonElement, active: boolean) { - if (active) { - element.textContent = "Enabled"; - element.style.color = "var(--accent-green)"; - element.style.borderColor = "var(--accent-green)"; - } else { - element.textContent = "Disabled"; - element.style.color = "var(--text-muted)"; - element.style.borderColor = "var(--border-color)"; - } +function renderSettings(): void { + btnStatusToggle.textContent = settings.enabled ? "AD BLOCKING ACTIVE" : "AD BLOCKING PAUSED"; + btnStatusToggle.style.color = settings.enabled ? "black" : "var(--accent-red)"; + btnStatusToggle.style.backgroundColor = settings.enabled ? "var(--accent-green)" : "var(--bg-surface)"; + btnDiagnosticsToggle.textContent = settings.diagnosticMode ? "Enabled" : "Disabled"; + btnDiagnosticsToggle.style.color = settings.diagnosticMode ? "var(--accent-green)" : "var(--text-muted)"; + btnDiagnosticsToggle.style.borderColor = settings.diagnosticMode ? "var(--accent-green)" : "var(--border-color)"; + btnMuteToggle.textContent = settings.muteDuringAds ? "Enabled" : "Disabled"; + btnMuteToggle.style.color = settings.muteDuringAds ? "var(--accent-green)" : "var(--text-muted)"; + btnMuteToggle.style.borderColor = settings.muteDuringAds ? "var(--accent-green)" : "var(--border-color)"; } -async function saveAndPublish() { +async function saveAndPublish(): Promise { await TwitchShieldStorage.saveSettings(settings); renderSettings(); - chrome.runtime.sendMessage({ action: "updateSettings", settings }, () => { - if (chrome.runtime.lastError) { - // background script asleep - } - }); + await chrome.runtime.sendMessage({ action: "updateSettings", settings }).catch(() => undefined); await refreshDiagnosticArea(); } -function renderWhitelist() { +function renderWhitelist(): void { whitelistBox.replaceChildren(); if (settings.whitelist.length === 0) { const empty = document.createElement("div"); empty.style.cssText = "text-align: center; color: var(--text-muted); padding: 20px;"; - empty.textContent = "No whitelisted channels defined"; + empty.textContent = "No excluded channels defined"; whitelistBox.appendChild(empty); return; } - settings.whitelist.forEach((channel) => { + for (const channel of settings.whitelist) { const row = document.createElement("div"); row.className = "list-item"; const label = document.createElement("span"); label.textContent = channel; const button = document.createElement("button"); button.className = "remove-btn"; - button.dataset.channel = channel; button.textContent = "Remove"; + button.addEventListener("click", () => { + settings.whitelist = settings.whitelist.filter((item) => item !== channel); + void saveAndPublish().then(renderWhitelist); + }); row.append(label, button); whitelistBox.appendChild(row); - }); - - // Bind clicks dynamically - whitelistBox.querySelectorAll(".remove-btn").forEach((btn) => { - btn.addEventListener("click", (e) => { - const targetChannel = (e.target as HTMLButtonElement).getAttribute("data-channel"); - if (targetChannel) { - settings.whitelist = settings.whitelist.filter((c) => c !== targetChannel); - saveAndPublish().then(() => renderWhitelist()); - } - }); - }); -} - -function addWhitelistChannel() { - const input = inputWhitelist.value.trim().toLowerCase(); - if (!input) return; - - if (settings.whitelist.includes(input)) { - alert("Channel is already on whitelist."); - return; } +} - settings.whitelist.push(input); +function addWhitelistChannel(): void { + const channel = inputWhitelist.value.trim().toLowerCase(); + if (!/^[a-z0-9_]{1,25}$/.test(channel) || settings.whitelist.includes(channel)) return; + settings.whitelist.push(channel); inputWhitelist.value = ""; - saveAndPublish().then(() => renderWhitelist()); + void saveAndPublish().then(renderWhitelist); } -async function refreshDiagnosticArea() { - const diagnostics = await TwitchShieldStorage.getDiagnostics(); - const manifestData = { - settings, - totalLogCount: diagnostics.length, - recordedLogs: diagnostics - }; - diagExportArea.value = JSON.stringify(manifestData, null, 2); +async function refreshDiagnosticArea(): Promise { + diagExportArea.value = JSON.stringify(await TwitchShieldStorage.getDiagnostics(), null, 2); } -function downloadDiagnosticReport() { - const content = diagExportArea.value; - const blob = new Blob([content], { type: "application/json" }); +async function downloadDiagnosticReport(): Promise { + await TwitchShieldStorage.appendDiagnostic({ + eventType: "diagnostic_export_requested", + browserTarget: __BROWSER_TARGET__, + pageCategory: "unknown", + status: "User requested a local diagnostic export." + }); + await refreshDiagnosticArea(); + const blob = new Blob([diagExportArea.value], { type: "application/json" }); const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `twitchshield-diagnostics-${Date.now()}.json`; - a.click(); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `twitchshield-diagnostics-${Date.now()}.json`; + anchor.click(); URL.revokeObjectURL(url); } -async function clearLocalCache() { - if (confirm("Are you sure you want to clear all local settings, rules caches, and logged diagnostics? This is irreversible.")) { - await TwitchShieldStorage.clearData(); - settings = { ...DEFAULT_SETTINGS }; - await saveAndPublish(); - renderWhitelist(); - await refreshDiagnosticArea(); - alert("All TwitchShield local state caches emptied."); - } +async function clearDiagnostics(): Promise { + await TwitchShieldStorage.clearDiagnostics(); + await refreshDiagnosticArea(); } -window.addEventListener("DOMContentLoaded", initOptions); +window.addEventListener("DOMContentLoaded", () => void initOptions()); diff --git a/TwitchShield/apps/chromium-extension/popup.html b/TwitchShield/apps/chromium-extension/popup.html index 13e1ba8..def44a1 100644 --- a/TwitchShield/apps/chromium-extension/popup.html +++ b/TwitchShield/apps/chromium-extension/popup.html @@ -12,47 +12,34 @@ --text-muted: #9ca3af; --accent-green: #10b981; --accent-red: #ef4444; - --primary: #6366f1; + --primary: #9146ff; --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } + * { box-sizing: border-box; } + body { width: 320px; margin: 0; - padding: 12px; + padding: 14px; background-color: var(--bg-base); color: var(--text-main); font-family: var(--font-family); font-size: 14px; - box-sizing: border-box; } .header { display: flex; align-items: center; justify-content: space-between; - margin-bottom: 12px; + margin-bottom: 14px; border-bottom: 1px solid var(--border-color); - padding-bottom: 8px; - } - - .logo-section { - display: flex; - align-items: center; - gap: 8px; - } - - .logo-icon { - color: var(--accent-green); - animation: heartbeat 2.5s infinite ease-in-out; - } - - .logo-title { - font-size: 16px; - font-weight: 700; - letter-spacing: -0.025em; + padding-bottom: 10px; } + .logo-section { display: flex; align-items: center; gap: 8px; } + .logo-icon { color: var(--primary); } + .logo-title { font-size: 16px; font-weight: 700; letter-spacing: -0.025em; } .version { font-size: 10px; color: var(--text-muted); @@ -62,12 +49,11 @@ border: 1px solid var(--border-color); } - /* Target state panel */ .switch-panel { background-color: var(--bg-card); border: 1px solid var(--border-color); - border-radius: 8px; - padding: 12px; + border-radius: 10px; + padding: 16px; text-align: center; margin-bottom: 12px; } @@ -79,211 +65,107 @@ border: 2px solid var(--border-color); background-color: var(--bg-base); color: var(--text-muted); - font-size: 24px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; - margin-bottom: 8px; + margin-bottom: 10px; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); - outline: none; } .power-btn.active { color: var(--accent-green); border-color: var(--accent-green); - box-shadow: 0 0 20px rgba(16, 185, 129, 0.15); + box-shadow: 0 0 22px rgba(16, 185, 129, 0.18); } - .status-text { - font-size: 15px; - font-weight: 600; - margin-top: 4px; - } - - .status-desc { - font-size: 11px; - color: var(--text-muted); - margin-top: 2px; - } - - /* Option checklist */ - .option-row { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 4px; - border-bottom: 1px solid rgba(33, 40, 54, 0.5); - } - - .option-info { - display: flex; - flex-direction: column; - } + .status-text { font-size: 15px; font-weight: 600; } + .status-desc { font-size: 11px; color: var(--text-muted); margin-top: 3px; } - .option-label { - font-weight: 500; - font-size: 12px; + .stat-row { display: flex; gap: 10px; margin-bottom: 12px; } + .stat-card { + flex: 1; + background-color: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 12px; + text-align: center; } - - .option-sub { - font-size: 10px; - color: var(--text-muted); + .stat-value { font-size: 22px; font-weight: 700; color: var(--accent-green); } + .stat-label { + font-size: 10px; color: var(--text-muted); text-transform: uppercase; + letter-spacing: 0.05em; margin-top: 2px; } + .stat-channel { font-size: 14px; font-weight: 600; color: var(--primary); word-break: break-all; } - /* Quick settings buttons */ - .mode-pills { - display: flex; - gap: 4px; - background-color: var(--bg-base); - padding: 2px; - border-radius: 6px; + .action-btn { + width: 100%; + background-color: var(--bg-card); border: 1px solid var(--border-color); - } - - .mode-pill { - flex: 1; - border: none; - background: transparent; - color: var(--text-muted); - font-size: 10px; + color: var(--text-main); + padding: 10px; + border-radius: 8px; + font-size: 12px; font-weight: 600; - padding: 4px 0; - border-radius: 4px; cursor: pointer; - transition: all 0.2s; + transition: border-color 0.2s, color 0.2s; + margin-bottom: 12px; } + .action-btn:hover:not(:disabled) { border-color: var(--primary); } + .action-btn:disabled { opacity: 0.5; cursor: default; } - .mode-pill.active { - background-color: var(--primary); - color: white; + .toast { + background-color: rgba(16, 185, 129, 0.12); + border: 1px solid var(--accent-green); + color: var(--accent-green); + border-radius: 8px; + padding: 8px 10px; + font-size: 11px; + text-align: center; + margin-bottom: 12px; + opacity: 0; + max-height: 0; + overflow: hidden; + transition: opacity 0.25s, max-height 0.25s; } + .toast.visible { opacity: 1; max-height: 60px; } - /* Diagnostic log stream */ .diagnostic-panel { background-color: var(--bg-card); border: 1px solid var(--border-color); - border-radius: 8px; - padding: 10px; - margin-top: 12px; + border-radius: 10px; + padding: 12px; } - .panel-header { - font-size: 11px; - font-weight: 700; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: 8px; - display: flex; - justify-content: space-between; + font-size: 11px; font-weight: 700; color: var(--text-muted); + text-transform: uppercase; letter-spacing: 0.05em; + margin-bottom: 8px; display: flex; justify-content: space-between; } - .log-list { - max-height: 100px; - overflow-y: auto; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + max-height: 120px; overflow-y: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 10px; - padding-right: 4px; } - .log-item { - padding: 4px 0; - border-bottom: 1px solid rgba(33, 40, 54, 0.3); - display: flex; - justify-content: space-between; - gap: 6px; - } - - .log-time { - color: var(--text-muted); - } - - .log-badge { - padding: 1px 4px; - border-radius: 3px; - font-weight: 700; - font-size: 8px; + padding: 4px 0; border-bottom: 1px solid rgba(33, 40, 54, 0.4); + display: flex; align-items: center; gap: 6px; } - + .log-time { color: var(--text-muted); flex-shrink: 0; } + .log-detail { flex: 1; color: var(--text-main); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .log-badge { padding: 1px 4px; border-radius: 3px; font-weight: 700; font-size: 8px; flex-shrink: 0; } .badge-ad { background-color: rgba(239, 68, 68, 0.15); color: var(--accent-red); } - .badge-recovery { background-color: rgba(99, 102, 241, 0.15); color: var(--primary); } - .badge-success { background-color: rgba(16, 185, 129, 0.15); color: var(--accent-green); } + .badge-info { background-color: rgba(145, 70, 255, 0.15); color: var(--primary); } + .log-empty { color: var(--text-muted); text-align: center; padding: 16px 4px; } .nav-links { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 12px; - padding-top: 8px; - border-top: 1px solid var(--border-color); + display: flex; justify-content: flex-end; align-items: center; + margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border-color); } - .nav-btn { - background: none; - border: none; - color: var(--text-muted); - font-size: 11px; - cursor: pointer; - display: flex; - align-items: center; - gap: 4px; - transition: color 0.2s; - } - - .nav-btn:hover { - color: var(--text-main); - } - - .switch { - position: relative; - display: inline-block; - width: 28px; - height: 16px; - } - - .switch input { - opacity: 0; - width: 0; - height: 0; - } - - .slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--border-color); - transition: .3s; - border-radius: 16px; - } - - .slider:before { - position: absolute; - content: ""; - height: 12px; - width: 12px; - left: 2px; - bottom: 2px; - background-color: white; - transition: .3s; - border-radius: 50%; - } - - input:checked + .slider { - background-color: var(--accent-green); - } - - input:checked + .slider:before { - transform: translateX(12px); - } - - @keyframes heartbeat { - 0%, 100% { transform: scale(1); filter: drop-shadow(0 0 0 rgba(16, 185, 129, 0)); } - 50% { transform: scale(1.05); filter: drop-shadow(0 0 6px rgba(16, 185, 129, 0.3)); } + background: none; border: none; color: var(--text-muted); + font-size: 11px; cursor: pointer; display: flex; align-items: center; gap: 4px; } + .nav-btn:hover { color: var(--text-main); } @@ -298,68 +180,41 @@
-
-
-
- Protection Mode - DNR ad block aggression levels +
+
+
0
+
Ads Blocked
-
- - - +
+
—
+
Current Channel
-
-
- Auto-Recovery Engine - Bypass black-screens and stalls -
- -
+ -
-
- Mute Stream Adverts - Zero commercial audio leaks -
- -
+
- On-Device Logs - 0 recorded + Recent Activity + 0 events
-
No events logged in this session
+
Enable diagnostics in the dashboard to see activity
- Mute Audio on Ads - Automatically drops tab audio during active commercials -
- -
- -
-
- Auto-Recovery Engine - Automatically reloads individual stream threads if the player crashes with error 3000 during ad checks + Local Diagnostic Mode + Store approved local event fields on this device; disabled by default
- +
- Reduce Quality During Ads - Drops player quality stream down to 360p inside the manifest to limit network congestion during mid-rolls + Mute during ad swap + Briefly mute the player while the ad-free stream loads, to avoid any ad audio
- +

Anonymized Technical Diagnostics Exporter

- Export technical event logs to help troubleshoot. TwitchShield exports only on-device timestamps, channel domains, block triggers, and recovery steps. + Export approved local observation events. No cookies, tokens, headers, full URLs, account data, chat, HTML snapshots, or network payloads are stored.

- +
@@ -257,6 +250,9 @@

Anonymized Technical Diagnostics Exporter

Whitelisted Channels

+

+ Ad blocking is disabled for channels in this list, so you can support your favorite streamers by letting their ads play. +

@@ -265,6 +261,19 @@

Whitelisted Channels

No whitelisted channels
+ +
+

Important Privacy Disclosure

+

Twitchshield runs entirely locally. It is free of:

+
    +
  • External trackers / telemetry
  • +
  • Centralized web analytics
  • +
  • Paid referral or ad affiliate networks
  • +
+

+ This extension utilizes host permissions solely on Twitch domains in order to inject clean player wrapper overlays and clean manifest streams. +

+
diff --git a/TwitchShield/apps/firefox-extension/options.ts b/TwitchShield/apps/firefox-extension/options.ts index 2b4984e..079aadf 100644 --- a/TwitchShield/apps/firefox-extension/options.ts +++ b/TwitchShield/apps/firefox-extension/options.ts @@ -1,27 +1,22 @@ import { TwitchShieldStorage } from "../../packages/core/src/storage.js"; -import { DEFAULT_SETTINGS, ExtensionSettings } from "../../packages/core/src/types.js"; +import { BrowserTarget, DEFAULT_SETTINGS, ExtensionSettings } from "../../packages/core/src/types.js"; -/** - * TwitchShield - Firefox Options & Diagnostics Dashboard TS - */ +declare const __BROWSER_TARGET__: BrowserTarget; -let settings: ExtensionSettings = DEFAULT_SETTINGS; +let settings: ExtensionSettings = { ...DEFAULT_SETTINGS }; const btnStatusToggle = document.getElementById("btn-status-toggle") as HTMLButtonElement; +const btnDiagnosticsToggle = document.getElementById("btn-diagnostics-toggle") as HTMLButtonElement; const btnMuteToggle = document.getElementById("btn-mute-toggle") as HTMLButtonElement; -const btnRecoverToggle = document.getElementById("btn-recover-toggle") as HTMLButtonElement; -const btnQualityToggle = document.getElementById("btn-quality-toggle") as HTMLButtonElement; - const inputWhitelist = document.getElementById("input-whitelist") as HTMLInputElement; const btnAddWhitelist = document.getElementById("btn-add-whitelist") as HTMLButtonElement; const whitelistBox = document.getElementById("whitelist-box") as HTMLDivElement; - const diagExportArea = document.getElementById("diag-export-area") as HTMLTextAreaElement; const btnRefreshLogs = document.getElementById("btn-refresh-logs") as HTMLButtonElement; const btnDownloadLogs = document.getElementById("btn-download-logs") as HTMLButtonElement; const btnClearLogs = document.getElementById("btn-clear-logs") as HTMLButtonElement; -async function initOptions() { +async function initOptions(): Promise { settings = await TwitchShieldStorage.getSettings(); renderSettings(); renderWhitelist(); @@ -29,151 +24,103 @@ async function initOptions() { btnStatusToggle.addEventListener("click", () => { settings.enabled = !settings.enabled; - saveAndPublish(); + void saveAndPublish(); + }); + btnDiagnosticsToggle.addEventListener("click", () => { + settings.diagnosticMode = !settings.diagnosticMode; + void saveAndPublish(); }); - btnMuteToggle.addEventListener("click", () => { settings.muteDuringAds = !settings.muteDuringAds; - saveAndPublish(); - }); - - btnRecoverToggle.addEventListener("click", () => { - settings.autoRecoveryEnabled = !settings.autoRecoveryEnabled; - saveAndPublish(); - }); - - btnQualityToggle.addEventListener("click", () => { - settings.lowQualityDuringAds = !settings.lowQualityDuringAds; - saveAndPublish(); + void saveAndPublish(); }); - btnAddWhitelist.addEventListener("click", addWhitelistChannel); - inputWhitelist.addEventListener("keypress", (e) => { - if (e.key === "Enter") addWhitelistChannel(); + inputWhitelist.addEventListener("keypress", (event) => { + if (event.key === "Enter") addWhitelistChannel(); }); - - btnRefreshLogs.addEventListener("click", refreshDiagnosticArea); - btnDownloadLogs.addEventListener("click", downloadDiagnosticReport); - btnClearLogs.addEventListener("click", clearLocalCache); + btnRefreshLogs.addEventListener("click", () => void refreshDiagnosticArea()); + btnDownloadLogs.addEventListener("click", () => void downloadDiagnosticReport()); + btnClearLogs.addEventListener("click", () => void clearDiagnostics()); } -function renderSettings() { - if (settings.enabled) { - btnStatusToggle.textContent = "SHIELD ACTIVE"; - btnStatusToggle.style.backgroundColor = "var(--accent-green)"; - btnStatusToggle.style.color = "black"; - } else { - btnStatusToggle.textContent = "SHIELD SUSPENDED"; - btnStatusToggle.style.backgroundColor = "var(--bg-surface)"; - btnStatusToggle.style.color = "var(--accent-red)"; - } - - updateToggleStyle(btnMuteToggle, settings.muteDuringAds); - updateToggleStyle(btnRecoverToggle, settings.autoRecoveryEnabled); - updateToggleStyle(btnQualityToggle, settings.lowQualityDuringAds); -} - -function updateToggleStyle(element: HTMLButtonElement, active: boolean) { - if (active) { - element.textContent = "Enabled"; - element.style.color = "var(--accent-green)"; - element.style.borderColor = "var(--accent-green)"; - } else { - element.textContent = "Disabled"; - element.style.color = "var(--text-muted)"; - element.style.borderColor = "var(--border-color)"; - } +function renderSettings(): void { + btnStatusToggle.textContent = settings.enabled ? "AD BLOCKING ACTIVE" : "AD BLOCKING PAUSED"; + btnStatusToggle.style.color = settings.enabled ? "black" : "var(--accent-red)"; + btnStatusToggle.style.backgroundColor = settings.enabled ? "var(--accent-green)" : "var(--bg-surface)"; + btnDiagnosticsToggle.textContent = settings.diagnosticMode ? "Enabled" : "Disabled"; + btnDiagnosticsToggle.style.color = settings.diagnosticMode ? "var(--accent-green)" : "var(--text-muted)"; + btnDiagnosticsToggle.style.borderColor = settings.diagnosticMode ? "var(--accent-green)" : "var(--border-color)"; + btnMuteToggle.textContent = settings.muteDuringAds ? "Enabled" : "Disabled"; + btnMuteToggle.style.color = settings.muteDuringAds ? "var(--accent-green)" : "var(--text-muted)"; + btnMuteToggle.style.borderColor = settings.muteDuringAds ? "var(--accent-green)" : "var(--border-color)"; } -async function saveAndPublish() { +async function saveAndPublish(): Promise { await TwitchShieldStorage.saveSettings(settings); renderSettings(); - chrome.runtime.sendMessage({ action: "updateSettings", settings }, () => { - if (chrome.runtime.lastError) { - // background script asleep - } - }); + await chrome.runtime.sendMessage({ action: "updateSettings", settings }).catch(() => undefined); await refreshDiagnosticArea(); } -function renderWhitelist() { +function renderWhitelist(): void { whitelistBox.replaceChildren(); if (settings.whitelist.length === 0) { const empty = document.createElement("div"); empty.style.cssText = "text-align: center; color: var(--text-muted); padding: 20px;"; - empty.textContent = "No whitelisted channels defined"; + empty.textContent = "No excluded channels defined"; whitelistBox.appendChild(empty); return; } - settings.whitelist.forEach((channel) => { + for (const channel of settings.whitelist) { const row = document.createElement("div"); row.className = "list-item"; const label = document.createElement("span"); label.textContent = channel; const button = document.createElement("button"); button.className = "remove-btn"; - button.dataset.channel = channel; button.textContent = "Remove"; + button.addEventListener("click", () => { + settings.whitelist = settings.whitelist.filter((item) => item !== channel); + void saveAndPublish().then(renderWhitelist); + }); row.append(label, button); whitelistBox.appendChild(row); - }); - - whitelistBox.querySelectorAll(".remove-btn").forEach((btn) => { - btn.addEventListener("click", (e) => { - const targetChannel = (e.target as HTMLButtonElement).getAttribute("data-channel"); - if (targetChannel) { - settings.whitelist = settings.whitelist.filter((c) => c !== targetChannel); - saveAndPublish().then(() => renderWhitelist()); - } - }); - }); -} - -function addWhitelistChannel() { - const input = inputWhitelist.value.trim().toLowerCase(); - if (!input) return; - - if (settings.whitelist.includes(input)) { - alert("Channel is already on whitelist."); - return; } +} - settings.whitelist.push(input); +function addWhitelistChannel(): void { + const channel = inputWhitelist.value.trim().toLowerCase(); + if (!/^[a-z0-9_]{1,25}$/.test(channel) || settings.whitelist.includes(channel)) return; + settings.whitelist.push(channel); inputWhitelist.value = ""; - saveAndPublish().then(() => renderWhitelist()); + void saveAndPublish().then(renderWhitelist); } -async function refreshDiagnosticArea() { - const diagnostics = await TwitchShieldStorage.getDiagnostics(); - const manifestData = { - settings, - totalLogCount: diagnostics.length, - recordedLogs: diagnostics - }; - diagExportArea.value = JSON.stringify(manifestData, null, 2); +async function refreshDiagnosticArea(): Promise { + diagExportArea.value = JSON.stringify(await TwitchShieldStorage.getDiagnostics(), null, 2); } -function downloadDiagnosticReport() { - const content = diagExportArea.value; - const blob = new Blob([content], { type: "application/json" }); +async function downloadDiagnosticReport(): Promise { + await TwitchShieldStorage.appendDiagnostic({ + eventType: "diagnostic_export_requested", + browserTarget: __BROWSER_TARGET__, + pageCategory: "unknown", + status: "User requested a local diagnostic export." + }); + await refreshDiagnosticArea(); + const blob = new Blob([diagExportArea.value], { type: "application/json" }); const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `twitchshield-firefox-diagnostics-${Date.now()}.json`; - a.click(); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `twitchshield-diagnostics-${Date.now()}.json`; + anchor.click(); URL.revokeObjectURL(url); } -async function clearLocalCache() { - if (confirm("Are you sure you want to clear all local Firefox state caches?")) { - await TwitchShieldStorage.clearData(); - settings = { ...DEFAULT_SETTINGS }; - await saveAndPublish(); - renderWhitelist(); - await refreshDiagnosticArea(); - alert("All TwitchShield state caches cleared."); - } +async function clearDiagnostics(): Promise { + await TwitchShieldStorage.clearDiagnostics(); + await refreshDiagnosticArea(); } -window.addEventListener("DOMContentLoaded", initOptions); +window.addEventListener("DOMContentLoaded", () => void initOptions()); diff --git a/TwitchShield/apps/firefox-extension/popup.html b/TwitchShield/apps/firefox-extension/popup.html index 690c8f0..def44a1 100644 --- a/TwitchShield/apps/firefox-extension/popup.html +++ b/TwitchShield/apps/firefox-extension/popup.html @@ -12,47 +12,34 @@ --text-muted: #9ca3af; --accent-green: #10b981; --accent-red: #ef4444; - --primary: #6366f1; + --primary: #9146ff; --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } + * { box-sizing: border-box; } + body { width: 320px; margin: 0; - padding: 12px; + padding: 14px; background-color: var(--bg-base); color: var(--text-main); font-family: var(--font-family); font-size: 14px; - box-sizing: border-box; } .header { display: flex; align-items: center; justify-content: space-between; - margin-bottom: 12px; + margin-bottom: 14px; border-bottom: 1px solid var(--border-color); - padding-bottom: 8px; - } - - .logo-section { - display: flex; - align-items: center; - gap: 8px; - } - - .logo-icon { - color: var(--accent-green); - animation: heartbeat 2.5s infinite ease-in-out; - } - - .logo-title { - font-size: 16px; - font-weight: 700; - letter-spacing: -0.025em; + padding-bottom: 10px; } + .logo-section { display: flex; align-items: center; gap: 8px; } + .logo-icon { color: var(--primary); } + .logo-title { font-size: 16px; font-weight: 700; letter-spacing: -0.025em; } .version { font-size: 10px; color: var(--text-muted); @@ -65,8 +52,8 @@ .switch-panel { background-color: var(--bg-card); border: 1px solid var(--border-color); - border-radius: 8px; - padding: 12px; + border-radius: 10px; + padding: 16px; text-align: center; margin-bottom: 12px; } @@ -78,208 +65,107 @@ border: 2px solid var(--border-color); background-color: var(--bg-base); color: var(--text-muted); - font-size: 24px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; - margin-bottom: 8px; + margin-bottom: 10px; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); - outline: none; } .power-btn.active { color: var(--accent-green); border-color: var(--accent-green); - box-shadow: 0 0 20px rgba(16, 185, 129, 0.15); + box-shadow: 0 0 22px rgba(16, 185, 129, 0.18); } - .status-text { - font-size: 15px; - font-weight: 600; - margin-top: 4px; - } - - .status-desc { - font-size: 11px; - color: var(--text-muted); - margin-top: 2px; - } - - .option-row { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 4px; - border-bottom: 1px solid rgba(33, 40, 54, 0.5); - } - - .option-info { - display: flex; - flex-direction: column; - } + .status-text { font-size: 15px; font-weight: 600; } + .status-desc { font-size: 11px; color: var(--text-muted); margin-top: 3px; } - .option-label { - font-weight: 500; - font-size: 12px; + .stat-row { display: flex; gap: 10px; margin-bottom: 12px; } + .stat-card { + flex: 1; + background-color: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 12px; + text-align: center; } - - .option-sub { - font-size: 10px; - color: var(--text-muted); + .stat-value { font-size: 22px; font-weight: 700; color: var(--accent-green); } + .stat-label { + font-size: 10px; color: var(--text-muted); text-transform: uppercase; + letter-spacing: 0.05em; margin-top: 2px; } + .stat-channel { font-size: 14px; font-weight: 600; color: var(--primary); word-break: break-all; } - .mode-pills { - display: flex; - gap: 4px; - background-color: var(--bg-base); - padding: 2px; - border-radius: 6px; + .action-btn { + width: 100%; + background-color: var(--bg-card); border: 1px solid var(--border-color); - } - - .mode-pill { - flex: 1; - border: none; - background: transparent; - color: var(--text-muted); - font-size: 10px; + color: var(--text-main); + padding: 10px; + border-radius: 8px; + font-size: 12px; font-weight: 600; - padding: 4px 0; - border-radius: 4px; cursor: pointer; - transition: all 0.2s; + transition: border-color 0.2s, color 0.2s; + margin-bottom: 12px; } + .action-btn:hover:not(:disabled) { border-color: var(--primary); } + .action-btn:disabled { opacity: 0.5; cursor: default; } - .mode-pill.active { - background-color: var(--primary); - color: white; + .toast { + background-color: rgba(16, 185, 129, 0.12); + border: 1px solid var(--accent-green); + color: var(--accent-green); + border-radius: 8px; + padding: 8px 10px; + font-size: 11px; + text-align: center; + margin-bottom: 12px; + opacity: 0; + max-height: 0; + overflow: hidden; + transition: opacity 0.25s, max-height 0.25s; } + .toast.visible { opacity: 1; max-height: 60px; } .diagnostic-panel { background-color: var(--bg-card); border: 1px solid var(--border-color); - border-radius: 8px; - padding: 10px; - margin-top: 12px; + border-radius: 10px; + padding: 12px; } - .panel-header { - font-size: 11px; - font-weight: 700; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: 8px; - display: flex; - justify-content: space-between; + font-size: 11px; font-weight: 700; color: var(--text-muted); + text-transform: uppercase; letter-spacing: 0.05em; + margin-bottom: 8px; display: flex; justify-content: space-between; } - .log-list { - max-height: 100px; - overflow-y: auto; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + max-height: 120px; overflow-y: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 10px; - padding-right: 4px; } - .log-item { - padding: 4px 0; - border-bottom: 1px solid rgba(33, 40, 54, 0.3); - display: flex; - justify-content: space-between; - gap: 6px; - } - - .log-time { - color: var(--text-muted); - } - - .log-badge { - padding: 1px 4px; - border-radius: 3px; - font-weight: 700; - font-size: 8px; + padding: 4px 0; border-bottom: 1px solid rgba(33, 40, 54, 0.4); + display: flex; align-items: center; gap: 6px; } - + .log-time { color: var(--text-muted); flex-shrink: 0; } + .log-detail { flex: 1; color: var(--text-main); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .log-badge { padding: 1px 4px; border-radius: 3px; font-weight: 700; font-size: 8px; flex-shrink: 0; } .badge-ad { background-color: rgba(239, 68, 68, 0.15); color: var(--accent-red); } - .badge-recovery { background-color: rgba(99, 102, 241, 0.15); color: var(--primary); } - .badge-success { background-color: rgba(16, 185, 129, 0.15); color: var(--accent-green); } + .badge-info { background-color: rgba(145, 70, 255, 0.15); color: var(--primary); } + .log-empty { color: var(--text-muted); text-align: center; padding: 16px 4px; } .nav-links { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 12px; - padding-top: 8px; - border-top: 1px solid var(--border-color); + display: flex; justify-content: flex-end; align-items: center; + margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border-color); } - .nav-btn { - background: none; - border: none; - color: var(--text-muted); - font-size: 11px; - cursor: pointer; - display: flex; - align-items: center; - gap: 4px; - transition: color 0.2s; - } - - .nav-btn:hover { - color: var(--text-main); - } - - .switch { - position: relative; - display: inline-block; - width: 28px; - height: 16px; - } - - .switch input { - opacity: 0; - width: 0; - height: 0; - } - - .slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--border-color); - transition: .3s; - border-radius: 16px; - } - - .slider:before { - position: absolute; - content: ""; - height: 12px; - width: 12px; - left: 2px; - bottom: 2px; - background-color: white; - transition: .3s; - border-radius: 50%; - } - - input:checked + .slider { - background-color: var(--accent-green); - } - - input:checked + .slider:before { - transform: translateX(12px); - } - - @keyframes heartbeat { - 0%, 100% { transform: scale(1); filter: drop-shadow(0 0 0 rgba(16, 185, 129, 0)); } - 50% { transform: scale(1.05); filter: drop-shadow(0 0 6px rgba(16, 185, 129, 0.3)); } + background: none; border: none; color: var(--text-muted); + font-size: 11px; cursor: pointer; display: flex; align-items: center; gap: 4px; } + .nav-btn:hover { color: var(--text-main); } @@ -294,68 +180,41 @@
-
-
-
- Protection Mode - DNR ad block aggression levels +
+
+
0
+
Ads Blocked
-
- - - +
+
—
+
Current Channel
-
-
- Auto-Recovery Engine - Bypass black-screens and stalls -
- -
+ -
-
- Mute Stream Adverts - Zero commercial audio leaks -
- -
+
- On-Device Logs - 0 recorded + Recent Activity + 0 events
-
No events logged in this session
+
Enable diagnostics in the dashboard to see activity