From 1a18d125f7f5d44ee7edbff4568505e94753fe8d Mon Sep 17 00:00:00 2001 From: wpggLabs Date: Thu, 18 Jun 2026 19:20:56 -0400 Subject: [PATCH 01/14] establish passive live observation baseline --- README.md | 10 +- .../apps/chromium-extension/background.ts | 90 +---- .../apps/chromium-extension/content.ts | 232 ++++++------- .../apps/chromium-extension/manifest.json | 5 +- .../apps/chromium-extension/options.html | 34 +- .../apps/chromium-extension/options.ts | 175 ++++------ .../apps/chromium-extension/popup.html | 20 +- TwitchShield/apps/chromium-extension/popup.ts | 40 +-- .../apps/firefox-extension/background.ts | 79 +---- .../apps/firefox-extension/content.ts | 209 ++++++------ .../apps/firefox-extension/manifest.json | 5 +- .../apps/firefox-extension/options.html | 32 +- .../apps/firefox-extension/options.ts | 171 ++++------ .../apps/firefox-extension/popup.html | 20 +- TwitchShield/apps/firefox-extension/popup.ts | 40 +-- TwitchShield/docs/research.md | 124 ------- TwitchShield/packages/core/src/observation.ts | 5 + TwitchShield/packages/core/src/storage.ts | 167 ++++------ TwitchShield/packages/core/src/types.ts | 43 ++- .../packages/rules/src/dynamic-rules.ts | 310 ------------------ .../packages/test-fixtures/test-runner.ts | 73 ----- TwitchShield/tests/browser-smoke.test.ts | 53 ++- TwitchShield/tests/diagnostics.test.ts | 71 ++++ TwitchShield/tests/simulation.test.ts | 35 -- docs/diagnostic-data-policy.md | 46 +++ docs/live-observation-report.md | 48 +++ docs/live-test-matrix.md | 17 + docs/next-phase-decision.md | 32 ++ package.json | 3 +- scripts/build-extensions.mjs | 1 + scripts/live-observation-chromium.mjs | 109 ++++++ 31 files changed, 861 insertions(+), 1438 deletions(-) delete mode 100644 TwitchShield/docs/research.md create mode 100644 TwitchShield/packages/core/src/observation.ts delete mode 100644 TwitchShield/packages/rules/src/dynamic-rules.ts delete mode 100644 TwitchShield/packages/test-fixtures/test-runner.ts create mode 100644 TwitchShield/tests/diagnostics.test.ts delete mode 100644 TwitchShield/tests/simulation.test.ts create mode 100644 docs/diagnostic-data-policy.md create mode 100644 docs/live-observation-report.md create mode 100644 docs/live-test-matrix.md create mode 100644 docs/next-phase-decision.md create mode 100644 scripts/live-observation-chromium.mjs diff --git a/README.md b/README.md index 6ac0ec4..1fede4d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # 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. +TwitchShield is a local-first Chromium and Firefox browser extension for passive Twitch page and player observation. Its diagnostic mode is optional, disabled by default, and stores only approved event fields on the device. It has no backend, analytics, telemetry upload, or remote executable code. -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. +Twitch ad-block effectiveness is **not verified** and this observation build performs no blocking. Automated coverage verifies diagnostic privacy rules, package integrity, Firefox manifest validation, Chromium popup/options loading, and opt-in local event recording. ## Requirements @@ -45,15 +45,17 @@ Archives are written to `artifacts/`. Each archive contains `manifest.json` at i ```powershell npm test npm run test:smoke:chromium +npm run test:live:chromium npm run test:firefox:lint ``` -- `test:unit`: local player-state simulations only; no browser or Twitch traffic. +- `test:unit`: local diagnostic storage and privacy-policy checks. - `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:live:chromium`: opens public Twitch live, VOD, and clip pages without login and reports passive observation evidence only. - `test:firefox:lint`: validates the generated Firefox extension with Mozilla `web-ext`. -No automated test currently verifies live Twitch playback or ad blocking. +No test verifies ad blocking, recovery, or the appearance of a real Twitch ad. ## Permissions diff --git a/TwitchShield/apps/chromium-extension/background.ts b/TwitchShield/apps/chromium-extension/background.ts index 0c79e89..0233689 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 + 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") { + 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..d96057a 100644 --- a/TwitchShield/apps/chromium-extension/content.ts +++ b/TwitchShield/apps/chromium-extension/content.ts @@ -1,162 +1,120 @@ 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; - -// 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."); -} - -// 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(); - } +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; + +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 - } - - 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."); +function observationDisabled(): boolean { + return isObservationDisabled(settings, channel); +} - 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."); }); - - observer.observe(document.documentElement, { - childList: true, - subtree: true + 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; + } }); } -// Monitor for stalled players (e.g. frozen video or 3000 error) -function setupRecoveryLoop() { - setInterval(() => { - if (!settings.enabled || !settings.autoRecoveryEnabled) return; +function inspectPage(): void { + if (observationDisabled()) return; + const video = document.querySelector("video"); + if (video instanceof HTMLVideoElement) bindVideo(video); - const videoElement = document.querySelector("video"); - if (!videoElement) return; + 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 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); - } - }, 4000); + if (playerFound) { + window.clearTimeout(mutationTimer); + mutationTimer = window.setTimeout(() => record("player_ui_changed", "Player UI structure changed."), 500); + } } -// 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); +async function startObservation(): Promise { + settings = await TwitchShieldStorage.getSettings(); + derivePageContext(); + 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(); + 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..505ccfe 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: Local Stream Observation", "version": "1.0.0", - "description": "High-security local stream ad-blocker and playback-recovery tool with zero telemetry and local diagnostic boards.", + "description": "Local, opt-in observation of Twitch page and player state with private on-device diagnostics.", "permissions": [ - "declarativeNetRequest", "activeTab", "storage" ], diff --git a/TwitchShield/apps/chromium-extension/options.html b/TwitchShield/apps/chromium-extension/options.html index a0ecb0f..e185a22 100644 --- a/TwitchShield/apps/chromium-extension/options.html +++ b/TwitchShield/apps/chromium-extension/options.html @@ -201,56 +201,40 @@

TwitchShield Workspace & Options

-
Complete localized protection controls, sandboxed whitelist, and on-device diagnostic exporter
+
Passive observation controls, channel exclusions, and on-device diagnostic export
-

Stream Protection & Engine Tuning

+

Observation Settings

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

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 +243,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! + Passive observation is disabled for channels in this list.

diff --git a/TwitchShield/apps/chromium-extension/options.ts b/TwitchShield/apps/chromium-extension/options.ts index 4f6a3ec..faf50a0 100644 --- a/TwitchShield/apps/chromium-extension/options.ts +++ b/TwitchShield/apps/chromium-extension/options.ts @@ -1,183 +1,118 @@ 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 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 btnDiagnosticsToggle = document.getElementById("btn-diagnostics-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(); - }); - - btnMuteToggle.addEventListener("click", () => { - settings.muteDuringAds = !settings.muteDuringAds; - saveAndPublish(); + void saveAndPublish(); }); - - btnRecoverToggle.addEventListener("click", () => { - settings.autoRecoveryEnabled = !settings.autoRecoveryEnabled; - saveAndPublish(); - }); - - btnQualityToggle.addEventListener("click", () => { - settings.lowQualityDuringAds = !settings.lowQualityDuringAds; - saveAndPublish(); + btnDiagnosticsToggle.addEventListener("click", () => { + settings.diagnosticMode = !settings.diagnosticMode; + 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 renderSettings(): void { + btnStatusToggle.textContent = settings.enabled ? "OBSERVATION ACTIVE" : "OBSERVATION 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)"; } -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)"; - } -} - -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..1f7d466 100644 --- a/TwitchShield/apps/chromium-extension/popup.html +++ b/TwitchShield/apps/chromium-extension/popup.html @@ -303,14 +303,14 @@ -
Active Shield
-
Protecting playback against ad markers
+
Observation Active
+
Passively observing page and player state
- Protection Mode - DNR ad block aggression levels + Observation Scope + No blocking or recovery actions are active
@@ -321,22 +321,22 @@
- Auto-Recovery Engine - Bypass black-screens and stalls + Recovery Actions + Inactive during observation phase
- Mute Stream Adverts - Zero commercial audio leaks + Player Mutation + Inactive during observation phase
diff --git a/TwitchShield/apps/chromium-extension/popup.ts b/TwitchShield/apps/chromium-extension/popup.ts index 49fcb2d..e720416 100644 --- a/TwitchShield/apps/chromium-extension/popup.ts +++ b/TwitchShield/apps/chromium-extension/popup.ts @@ -1,5 +1,5 @@ import { TwitchShieldStorage } from "../../packages/core/src/storage.js"; -import { DEFAULT_SETTINGS, ProtectionMode, ExtensionSettings } from "../../packages/core/src/types.js"; +import { DEFAULT_SETTINGS, ExtensionSettings } from "../../packages/core/src/types.js"; /** * TwitchShield - Popup Scripts @@ -30,18 +30,6 @@ async function initPopup() { // Install basic listeners powerBtn.addEventListener("click", togglePower); - toggleRecovery.addEventListener("change", (e) => { - settings.autoRecoveryEnabled = (e.target as HTMLInputElement).checked; - saveAndNotify(); - }); - toggleMute.addEventListener("change", (e) => { - settings.muteDuringAds = (e.target as HTMLInputElement).checked; - saveAndNotify(); - }); - - modeCompat.addEventListener("click", () => selectMode(ProtectionMode.COMPATIBILITY)); - modeBalanced.addEventListener("click", () => selectMode(ProtectionMode.BALANCED)); - modeAggro.addEventListener("click", () => selectMode(ProtectionMode.AGGRESSIVE)); btnWhitelist.addEventListener("click", executeWhitelistAction); btnOptions.addEventListener("click", () => { @@ -57,32 +45,24 @@ function updateUIState() { // Power Toggle if (settings.enabled) { powerBtn.classList.add("active"); - statusLabel.textContent = "Active Shield"; + statusLabel.textContent = "Observation Active"; statusLabel.style.color = "var(--accent-green)"; } else { powerBtn.classList.remove("active"); - statusLabel.textContent = "Shield Paused"; + statusLabel.textContent = "Observation Paused"; statusLabel.style.color = "var(--text-muted)"; } // Checkboxes - toggleRecovery.checked = settings.autoRecoveryEnabled; - toggleMute.checked = settings.muteDuringAds; + toggleRecovery.checked = false; + toggleMute.checked = false; // Pills modeCompat.classList.remove("active"); modeBalanced.classList.remove("active"); modeAggro.classList.remove("active"); - if (settings.mode === ProtectionMode.COMPATIBILITY) modeCompat.classList.add("active"); - else if (settings.mode === ProtectionMode.BALANCED) modeBalanced.classList.add("active"); - else if (settings.mode === ProtectionMode.AGGRESSIVE) modeAggro.classList.add("active"); -} - -async function selectMode(mode: ProtectionMode) { - settings.mode = mode; - updateUIState(); - await saveAndNotify(); + modeBalanced.classList.add("active"); } async function togglePower() { @@ -131,7 +111,7 @@ async function executeWhitelistAction() { alert(`Removed ${channelName} from Whitelist.`); } else { settings.whitelist.push(channelName); - alert(`Whitelisted ${channelName}. TwitchShield will skip ad-blocking on this channel.`); + alert(`Excluded ${channelName}. TwitchShield will skip observation on this channel.`); } await saveAndNotify(); @@ -152,8 +132,8 @@ async function loadLogs() { const item = document.createElement("div"); item.className = "log-item"; - const labelBadge = lg.eventType === "ad_detected" ? "badge-ad" : - lg.eventType === "success" ? "badge-success" : "badge-recovery"; + const labelBadge = lg.eventType === "ad_ui_signal_detected" ? "badge-ad" : + lg.eventType === "video_playing" ? "badge-success" : "badge-recovery"; const shortTime = new Date(lg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); @@ -161,7 +141,7 @@ async function loadLogs() { time.className = "log-time"; time.textContent = `[${shortTime}] [${lg.channel}]`; const details = document.createElement("span"); - details.textContent = lg.details; + details.textContent = lg.status; const badge = document.createElement("span"); badge.className = `log-badge ${labelBadge}`; badge.textContent = lg.eventType.replace("_", " "); diff --git a/TwitchShield/apps/firefox-extension/background.ts b/TwitchShield/apps/firefox-extension/background.ts index f8adf5b..0233689 100644 --- a/TwitchShield/apps/firefox-extension/background.ts +++ b/TwitchShield/apps/firefox-extension/background.ts @@ -1,75 +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 - Firefox Background Script (MV3 compatible) - */ - -chrome.runtime.onInstalled.addListener(async (details) => { - console.log("TwitchShield Firefox onInstalled:", details.reason); - const settings = await TwitchShieldStorage.getSettings(); - if (!settings) { - await TwitchShieldStorage.saveSettings(DEFAULT_SETTINGS); - } - await updateFirefoxRules(); - await TwitchShieldStorage.appendDiagnostic( - "FirefoxSystem", - "recovery_action", - `TwitchShield initialized on Firefox context.` - ); -}); - -chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { +chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { if (request.action === "updateSettings") { - TwitchShieldStorage.saveSettings(request.settings) - .then(() => updateFirefoxRules()) - .then(() => { - sendResponse({ success: true }); - 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; + 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") { + TwitchShieldStorage.appendDiagnostic(request.event).then((event) => sendResponse({ success: true, event })); return true; } }); - -async function updateFirefoxRules() { - const settings = await TwitchShieldStorage.getSettings(); - const rules = getDeclarativeRules(); - const ruleIdsToRemove = rules.map((r) => r.id); - - if (settings.enabled && settings.mode !== ProtectionMode.COMPATIBILITY) { - 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 { - await chrome.declarativeNetRequest.updateDynamicRules({ - removeRuleIds: ruleIdsToRemove - }); - } -} diff --git a/TwitchShield/apps/firefox-extension/content.ts b/TwitchShield/apps/firefox-extension/content.ts index 9b615a0..d96057a 100644 --- a/TwitchShield/apps/firefox-extension/content.ts +++ b/TwitchShield/apps/firefox-extension/content.ts @@ -1,139 +1,120 @@ import { TwitchShieldStorage } from "../../packages/core/src/storage.js"; -import { ExtensionSettings, DEFAULT_SETTINGS } from "../../packages/core/src/types.js"; - -/** - * TwitchShield - Firefox Content Script - */ - -let settings: ExtensionSettings = DEFAULT_SETTINGS; -let activeChannel = "unknown"; -let isAdActive = false; -let originalVolume = 0.5; - -async function initContentScript() { - settings = await TwitchShieldStorage.getSettings(); - resolveChannelName(); - setupObserver(); - setupRecoveryLoop(); - injectMainWorldIntercept(); - sendLog("method_attempted", "TwitchShield monitors launched in Firefox context."); +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; + +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; } -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 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 - }, () => { - if (chrome.runtime.lastError) { - // Background worker inactive - } - }); + }; + chrome.runtime.sendMessage({ action: "recordDiagnostic", event }).catch(() => undefined); } -function setupObserver() { - const observer = new MutationObserver(() => { - if (!settings.enabled) return; - if (settings.whitelist.map(c => c.toLowerCase()).includes(activeChannel)) return; - - const videoElement = document.querySelector("video"); - if (!videoElement) return; - - const adIndicator = document.querySelector('[data-a-target="video-ad-label"], [data-a-target="player-overlay-advertisement"]'); - - if (adIndicator && !isAdActive) { - isAdActive = true; - originalVolume = videoElement.volume; - sendLog("ad_detected", "Commercial block detected on Firefox stream tab."); +function observationDisabled(): boolean { + return isObservationDisabled(settings, channel); +} - if (settings.muteDuringAds) { - videoElement.muted = true; - sendLog("method_attempted", "Auto-muted audio stream to prevent commercial noise."); - } - } else if (!adIndicator && isAdActive) { - isAdActive = false; - if (settings.muteDuringAds && videoElement.muted) { - videoElement.muted = false; - videoElement.volume = originalVolume; - sendLog("success", "Commercial block completed. Restored 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."); }); - - observer.observe(document.documentElement, { - childList: true, - subtree: true + 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; + } }); } -function setupRecoveryLoop() { - setInterval(() => { - if (!settings.enabled || !settings.autoRecoveryEnabled) return; +function inspectPage(): void { + if (observationDisabled()) return; + const video = document.querySelector("video"); + if (video instanceof HTMLVideoElement) bindVideo(video); - const videoElement = document.querySelector("video"); - if (!videoElement) return; + 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 (videoElement.readyState < 2 && !videoElement.paused && isAdActive) { - sendLog("playback_stalled", "Twitch stream player stalled during ad; requesting restoration feed."); - const prevSrc = videoElement.src; - videoElement.src = ""; - videoElement.load(); - setTimeout(() => { - videoElement.src = prevSrc; - videoElement.play().then(() => { - sendLog("recovery_action", "Firefox playback stream reloaded successfully."); - }).catch(() => { - sendLog("failure", "Failed to force restart Firefox play engine."); - }); - }, 500); - } - }, 4000); + if (playerFound) { + window.clearTimeout(mutationTimer); + mutationTimer = window.setTimeout(() => record("player_ui_changed", "Player UI structure changed."), 500); + } } -function injectMainWorldIntercept() { - try { - const script = document.createElement("script"); - script.textContent = ` - (function() { - 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/')) { - 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 Firefox intercept inject error:", e); +async function startObservation(): Promise { + settings = await TwitchShieldStorage.getSettings(); + derivePageContext(); + 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 }); } chrome.runtime.onMessage.addListener((request) => { if (request.action === "syncSettings") { + const wasObserving = settings.enabled && settings.diagnosticMode; settings = request.settings; - resolveChannelName(); + derivePageContext(); + if (wasObserving && observationDisabled() && channel) { + record("extension_disabled_on_channel", "Observation disabled for this channel."); + } + inspectPage(); } }); -initContentScript(); -window.addEventListener("navigation", () => { - resolveChannelName(); -}); +void startObservation(); diff --git a/TwitchShield/apps/firefox-extension/manifest.json b/TwitchShield/apps/firefox-extension/manifest.json index 4c6fd36..f861d53 100644 --- a/TwitchShield/apps/firefox-extension/manifest.json +++ b/TwitchShield/apps/firefox-extension/manifest.json @@ -1,10 +1,9 @@ { "manifest_version": 3, - "name": "TwitchShield: Ad Blocker & Stream Recovery", + "name": "TwitchShield: Local Stream Observation", "version": "1.0.0", - "description": "Firefox WebExtension for Twitch stream protection and local playback error recovery. Complete privacy guaranteed.", + "description": "Local, opt-in observation of Twitch page and player state with private on-device diagnostics.", "permissions": [ - "declarativeNetRequest", "activeTab", "storage" ], diff --git a/TwitchShield/apps/firefox-extension/options.html b/TwitchShield/apps/firefox-extension/options.html index a4d51bf..74a618c 100644 --- a/TwitchShield/apps/firefox-extension/options.html +++ b/TwitchShield/apps/firefox-extension/options.html @@ -200,56 +200,40 @@

TwitchShield Firefox Workspace

-
Firefox protective settings panel and on-device diagnostic logs
+
Firefox passive observation settings and on-device diagnostic logs
-

Stream Protection & Engine Tuning

+

Observation Settings

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

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.

- +
diff --git a/TwitchShield/apps/firefox-extension/options.ts b/TwitchShield/apps/firefox-extension/options.ts index 2b4984e..faf50a0 100644 --- a/TwitchShield/apps/firefox-extension/options.ts +++ b/TwitchShield/apps/firefox-extension/options.ts @@ -1,27 +1,21 @@ 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 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 btnDiagnosticsToggle = document.getElementById("btn-diagnostics-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 +23,96 @@ async function initOptions() { btnStatusToggle.addEventListener("click", () => { settings.enabled = !settings.enabled; - saveAndPublish(); - }); - - btnMuteToggle.addEventListener("click", () => { - settings.muteDuringAds = !settings.muteDuringAds; - saveAndPublish(); + void saveAndPublish(); }); - - btnRecoverToggle.addEventListener("click", () => { - settings.autoRecoveryEnabled = !settings.autoRecoveryEnabled; - saveAndPublish(); - }); - - btnQualityToggle.addEventListener("click", () => { - settings.lowQualityDuringAds = !settings.lowQualityDuringAds; - saveAndPublish(); + btnDiagnosticsToggle.addEventListener("click", () => { + settings.diagnosticMode = !settings.diagnosticMode; + 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 renderSettings(): void { + btnStatusToggle.textContent = settings.enabled ? "OBSERVATION ACTIVE" : "OBSERVATION 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)"; } -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)"; - } -} - -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..f7eb85b 100644 --- a/TwitchShield/apps/firefox-extension/popup.html +++ b/TwitchShield/apps/firefox-extension/popup.html @@ -299,14 +299,14 @@ -
Active Shield
-
Protecting playback against ad markers
+
Observation Active
+
Passively observing page and player state
- Protection Mode - DNR ad block aggression levels + Observation Scope + No blocking or recovery actions are active
@@ -317,22 +317,22 @@
- Auto-Recovery Engine - Bypass black-screens and stalls + Recovery Actions + Inactive during observation phase
- Mute Stream Adverts - Zero commercial audio leaks + Player Mutation + Inactive during observation phase
diff --git a/TwitchShield/apps/firefox-extension/popup.ts b/TwitchShield/apps/firefox-extension/popup.ts index 13f05e6..27b8532 100644 --- a/TwitchShield/apps/firefox-extension/popup.ts +++ b/TwitchShield/apps/firefox-extension/popup.ts @@ -1,5 +1,5 @@ import { TwitchShieldStorage } from "../../packages/core/src/storage.js"; -import { DEFAULT_SETTINGS, ProtectionMode, ExtensionSettings } from "../../packages/core/src/types.js"; +import { DEFAULT_SETTINGS, ExtensionSettings } from "../../packages/core/src/types.js"; /** * TwitchShield - Firefox Popup JS @@ -27,18 +27,6 @@ async function initPopup() { await loadLogs(); powerBtn.addEventListener("click", togglePower); - toggleRecovery.addEventListener("change", (e) => { - settings.autoRecoveryEnabled = (e.target as HTMLInputElement).checked; - saveAndNotify(); - }); - toggleMute.addEventListener("change", (e) => { - settings.muteDuringAds = (e.target as HTMLInputElement).checked; - saveAndNotify(); - }); - - modeCompat.addEventListener("click", () => selectMode(ProtectionMode.COMPATIBILITY)); - modeBalanced.addEventListener("click", () => selectMode(ProtectionMode.BALANCED)); - modeAggro.addEventListener("click", () => selectMode(ProtectionMode.AGGRESSIVE)); btnWhitelist.addEventListener("click", executeWhitelistAction); btnOptions.addEventListener("click", () => { @@ -53,30 +41,22 @@ async function initPopup() { function updateUIState() { if (settings.enabled) { powerBtn.classList.add("active"); - statusLabel.textContent = "Active Shield"; + statusLabel.textContent = "Observation Active"; statusLabel.style.color = "var(--accent-green)"; } else { powerBtn.classList.remove("active"); - statusLabel.textContent = "Shield Paused"; + statusLabel.textContent = "Observation Paused"; statusLabel.style.color = "var(--text-muted)"; } - toggleRecovery.checked = settings.autoRecoveryEnabled; - toggleMute.checked = settings.muteDuringAds; + toggleRecovery.checked = false; + toggleMute.checked = false; modeCompat.classList.remove("active"); modeBalanced.classList.remove("active"); modeAggro.classList.remove("active"); - if (settings.mode === ProtectionMode.COMPATIBILITY) modeCompat.classList.add("active"); - else if (settings.mode === ProtectionMode.BALANCED) modeBalanced.classList.add("active"); - else if (settings.mode === ProtectionMode.AGGRESSIVE) modeAggro.classList.add("active"); -} - -async function selectMode(mode: ProtectionMode) { - settings.mode = mode; - updateUIState(); - await saveAndNotify(); + modeBalanced.classList.add("active"); } async function togglePower() { @@ -123,7 +103,7 @@ async function executeWhitelistAction() { alert(`Removed ${channelName} from Whitelist.`); } else { settings.whitelist.push(channelName); - alert(`Whitelisted ${channelName}. TwitchShield will skip ad-blocking on this channel.`); + alert(`Excluded ${channelName}. TwitchShield will skip observation on this channel.`); } await saveAndNotify(); @@ -144,8 +124,8 @@ async function loadLogs() { const item = document.createElement("div"); item.className = "log-item"; - const labelBadge = lg.eventType === "ad_detected" ? "badge-ad" : - lg.eventType === "success" ? "badge-success" : "badge-recovery"; + const labelBadge = lg.eventType === "ad_ui_signal_detected" ? "badge-ad" : + lg.eventType === "video_playing" ? "badge-success" : "badge-recovery"; const shortTime = new Date(lg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); @@ -153,7 +133,7 @@ async function loadLogs() { time.className = "log-time"; time.textContent = `[${shortTime}] [${lg.channel}]`; const details = document.createElement("span"); - details.textContent = lg.details; + details.textContent = lg.status; const badge = document.createElement("span"); badge.className = `log-badge ${labelBadge}`; badge.textContent = lg.eventType.replace("_", " "); diff --git a/TwitchShield/docs/research.md b/TwitchShield/docs/research.md deleted file mode 100644 index 3827ccf..0000000 --- a/TwitchShield/docs/research.md +++ /dev/null @@ -1,124 +0,0 @@ -# TwitchShield Ad-Blocking Research & Architecture Decision - -## 1. Twitch Ad-Delivery Behavior Analysis - -Twitch utilizes a sophisticated Server-Side Ad Insertion (SSAI) delivery model. Rather than serving commercials via client-side advertising networks (which are easily intercepted by standard DNS/Request-level blocklists like uBlock Origin), Twitch compiles ad media blocks directly into the Master and Media Playlist manifests (`index.m3u8`). - -### Core Mechanics: -1. **Usher Endpoint Manipulation**: - - When a client requests a live channel stream, the Amazon Video Player queries the Twitch Usher API: `https://usher.ttvnw.net/api/channel/hls/{channel}.m3u8`. - - Usher responds with a signed master playlist redirect to an Amazon CloudFront Edge node. - - For ad insertion, the Usher service evaluates user information (IP geo-location, user-agent, subscription status, login tokens) and returns manifests configured with ad-segment triggers (`#EXT-X-DISCONTINUITY` or custom `#EXT-X-TWITCH-PRE-ROLL` metadata tags). - -2. **Server-Side Integration**: - - Ads are transcoded to match the stream's dimensions and frame rate, then dynamically multiplexed directly into the transport stream (`.ts`) chunks, or served as secondary alternate streams. - - If an ad-blocker attempts to drop or nullify ad segments, the player engine detects the missing chunks, causing buffering circles, stalled players, or a complete crash of the playback loop (the dreaded "Success/Black-Screen Error 3000/4000"). - -3. **"SureStream" and Client-Side Signals**: - - Client-side scripts track player telemetry. If the user mutes or tries to hide the ad panel, Twitch's tracking scripts report the inactivity. - - Competing ad-delivery servers will downgrade stream playback quality down to `360p` during mid-rolls/pre-rolls if ad telemetry signals are blocked. - ---- - -## 2. Browser Extension Architectures: chrome (Manifest V3) vs. Firefox (WebExtensions MV2/MV3) - -| Metric | Google Chrome (Manifest V3) | Firefox (MV2 / MV3 Dual-Use) | -| :--- | :--- | :--- | -| **Request Interception** | Restricted to `declarativeNetRequest` (static rules or dynamic rule changes). Fully synchronous `webRequestBlocking` is deprecated or unavailable for high-tier extensions. | Supported via `webRequest.onBeforeRequest` with blocking capabilities, making dynamic m3u8 modifications reliable. | -| **DOM / Page Injection** | Injection of scripts into the page context ("MAIN" world) is allowed but strictly permission-gated. Host permissions are scrutinized closely. | Allows script injection through DOM manipulation or custom APIs, with a highly flexible user-script sandboxing environment. | -| **Service Workers** | Background scripts run as transient Service Workers that sleep after ~5 minutes of idle time. Persistent variables must go to `chrome.storage.local`. | Background pages can run persistently or as Event pages depending on whether it's an MV2 or MV3 build. | - ---- - -## 3. Alternative Approaches & Failure Modes - -### Method A: Proxying / M3U8 Rewriting (The "Usher Bypass") -- **Mechanism**: The extension redirects Usher requests to secure, external, ad-free proxies in specific countries where Twitch does not run ad campaigns (e.g., Ukraine, South Korea [partially/historically], Costa Rica, Georgia, Albania). -- **Pros**: 100% ad-free stream directly from Amazon CDN. Very low latency. -- **Cons**: High proxy maintenance costs, potential latency / CDN routing sluggishness, privacy risks if third parties control the proxy, and constant patching when Twitch flags proxy IP pools. - -### Method B: Amazon Video Player (AVP) Web-Worker Overriding -- **Mechanism**: The extension intercepts the network loading of Twitch's main JavaScript client/player worker script. It modifies the player worker's internal fetch mechanism to remove query/request parameters indicative of ad-triggering or filters out ad tags from the `.m3u8` manifest before passing them to the player engine. -- **Pros**: Purely local. No external servers or proxies. Extremely reliable since it operates inside the raw player parser. -- **Cons**: High reverse-engineering complexity. If Amazon updates the compiled format of the Web Worker file (usually dynamically named), the regex patches fail and stream breaks. - -### Method C: DOM-Based Overlay and Automated Fast-Forwarding / Recovery -- **Mechanism**: Monitors DOM elements for the ad disclaimer panels, overlay alerts, or target tags (`[data-a-target="video-ad-label"]`). Once detected: - 1. Set player `playbackRate` to standard higher speeds (1.0x to 5.0x if supported) or seek forward. - 2. Unmute or mute automatically. - 3. Overlay a fallback UI representing active defense. - 4. Perform immediate playlist reload on a silent secondary player when a stalls/buffer is found. -- **Pros**: Browser-agnostic, low risk of breaking stream mechanics, does not load remote scripts, matches the store policy perfectly. -- **Cons**: User may experience brief flashes of ads (1-2 seconds) before automated fast-forwarding/muting clamps down. - ---- - -## 4. TwitchShield Unified Architectural Decision - -To deliver the absolute most resilient, robust, store-compliant system, **TwitchShield** adopts a **hybrid multi-tiered recovery model**: - -1. **Tier 1: Declarative and Dynamic Rules (Request Level)**: - - Configures headers, strips tracking queries, and redirects ad-tracking scripts by utilizing Chrome `declarativeNetRequest` (MV3) and Firefox `webRequest` (MV2/MV3 wrappers) to strip known ad-beacon calls (like standard telemetry endpoints and external Amazon Ad Server routes). - -2. **Tier 2: Amazon Video Player (AVP) Safe Web-Worker Override (Local)**: - - Registers a local script in the page context (`MAIN` world) that overrides the `window.Worker` and `window.WebSocket` constructors. - - Intercepts requests for `.m3u8` playlists and individual `.ts` chunks. - - Cleans ad segments locally in memory, and suppresses player-telemetry beacons informing Twitch that ads failed. - -3. **Tier 3: DOM Vigilance & Fast Recovery**: - - Operates a reliable, background-monitored Mutation Observer that detects ad banners, sidecar companion banners, and video player statuses. - - If a stream stalls or blacks out (Error 3000) or displays severe anomalies, triggers a fast internal playback reset (re-attaches source / drops buffer cache) to restore normal live broadcast in `< 1.2s`. - ---- - -## 5. Advanced Ad Detection Techniques & Heuristics - -TwitchShield integrates a comprehensive heuristics processor inside `/packages/rules/src/dynamic-rules.ts`. By combining multi-faceted telemetry checks, it accurately detects ad sequences without hardcoded blocklists. - -### 5.1 Network Request Pattern Profiling -Traditional ad-blocking monitors static URLs. TwitchShield expands on this by inspecting query signatures that occur during commercial bids: -- **Identifier Tracking (`uuid=`, `ad_debug`)**: Inspects incoming request headers and query parameters for ad-server analytics trackers and debug sequences. -- **Dynamic Action Signals (`ad_sign`, `play_ad`, `client_ads`)**: Intercepts WebSocket handshakes and fetches and checks for client authorization signatures allowing the video player to load specialized server-side ad pods. -- **Header Fingerprinting**: Analyzes specific network response headers (e.g. `x-advertiser`, `x-ima-version`) to block third-party scripts before they initiate DOM canvas rendering. - -### 5.2 Timing Anomalies & HLS Playlist Drift Heuristics -Because Server-Side Ad Insertion (SSAI) overlays dynamic ad blocks directly into streams, it leaves detectable mathematical anomalies in the `.m3u8` playlist files. TwitchShield parses these manifests locally to expose three timing anomalies: -- **Segment Duration Drift**: In standard live streaming, media segments have high uniformity (e.g., precisely `4.000s` or `6.000s`). Ads are spliced in non-standard increments to fill exact slot timers. TwitchShield tracks segment deviation; if segment lengths vary from target duration repeatedly, a timing drift is triggered. -- **Discontinuity Density**: Ad pod boundaries necessitate `#EXT-X-DISCONTINUITY` directives to re-align player audio/video clocks. A high frequency of discontinuity switches within a single media playlist window indicates active commercial splices. -- **Ad Pod Segment Sizing**: Analyzes segment sizes inside a discontinuity group. When combined with specific ad-blocking parameters (e.g., short 1.1s segment clusters), it confidently marks upcoming chunks as ad payloads. - -### 5.3 Observable Client-Side Behaviors -- **VBR Resolution Regression**: During aggressive ad detection, Twitch-side scripts often push the incoming stream down to `360p`/`480p` if telemetry responses are blocked. Detecting unprompted downgrades combined with player volume mutations serves as a proxy indicator that an ad-loop is occurring. -- **Mutation Handshaking**: Uses DOM queries targeting specific wrapper containers (e.g., `.video-ad-label`, `[data-a-target="video-ad-label"]`) to synchronize state between the background worker and the local player mute controllers. - ---- - -## 6. Auto-Recovery Engineering Specifications & Interruption Resilience - -When blocking ads on highly locked-down manifest pipelines, streaming failures are common. Twitch's player frequently triggers freezing, indefinite spinning buffers, audio-only bugs, or absolute crashes (Error 3000/4000) rather than playing clean stream segments. - -To address this, TwitchShield operates an active **Auto-Recovery Loop (Tier 3)**: - -### 6.1 Interruption Signatures -The recovery engine continuously monitors for four critical interruption signatures: -1. **Error 3000 / 4000 States**: The underlying `