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 @@
-
-
+
+
- Active Shield
- Protecting playback against ad markers
+ Ad Blocking Active
+ Stream-level ads are being removed on Twitch
-
-
-
Protection Mode
-
DNR ad block aggression levels
+
+
-
-
-
- Auto-Recovery Engine
- Bypass black-screens and stalls
-
-
-
-
-
-
+
Whitelist this channel
-
-
- Mute Stream Adverts
- Zero commercial audio leaks
-
-
-
-
-
-
+
-
No events logged in this session
+
Enable diagnostics in the dashboard to see activity
-
-
-
-
-
-
- Whitelist Channel
-
diff --git a/TwitchShield/apps/chromium-extension/popup.ts b/TwitchShield/apps/chromium-extension/popup.ts
index 49fcb2d..aeecc02 100644
--- a/TwitchShield/apps/chromium-extension/popup.ts
+++ b/TwitchShield/apps/chromium-extension/popup.ts
@@ -1,173 +1,159 @@
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
+ * Popup controller.
+ *
+ * Reflects the real extension state: whether ad blocking is active, how many ads
+ * were removed this session, and the whitelist status of the current channel.
+ * Every control reads and writes actual settings — nothing is hard-coded.
*/
-let settings: ExtensionSettings = DEFAULT_SETTINGS;
+let settings: ExtensionSettings = { ...DEFAULT_SETTINGS };
const powerBtn = document.getElementById("power-button") as HTMLButtonElement;
const statusLabel = document.getElementById("status-label") as HTMLDivElement;
-const toggleRecovery = document.getElementById("toggle-recovery") as HTMLInputElement;
-const toggleMute = document.getElementById("toggle-mute") as HTMLInputElement;
+const statusDesc = document.getElementById("status-desc") as HTMLDivElement;
+const blockedCount = document.getElementById("blocked-count") as HTMLDivElement;
+const channelName = document.getElementById("channel-name") as HTMLSpanElement;
+const btnWhitelist = document.getElementById("btn-whitelist") as HTMLButtonElement;
+const btnOptions = document.getElementById("btn-options") as HTMLButtonElement;
+const statusToast = document.getElementById("status-toast") as HTMLDivElement;
const logStream = document.getElementById("log-stream") as HTMLDivElement;
const logCountLabel = document.getElementById("log-count") as HTMLSpanElement;
-// Mode Pills
-const modeCompat = document.getElementById("mode-compat") as HTMLButtonElement;
-const modeBalanced = document.getElementById("mode-balanced") as HTMLButtonElement;
-const modeAggro = document.getElementById("mode-aggro") as HTMLButtonElement;
-
-// Nav buttons
-const btnWhitelist = document.getElementById("btn-whitelist") as HTMLButtonElement;
-const btnOptions = document.getElementById("btn-options") as HTMLButtonElement;
+let currentChannel: string | null = null;
-async function initPopup() {
+async function initPopup(): Promise {
settings = await TwitchShieldStorage.getSettings();
+ await resolveCurrentChannel();
updateUIState();
- await loadLogs();
-
- // 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();
- });
+ await loadStats();
- 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", () => {
- if (chrome.runtime.openOptionsPage) {
- chrome.runtime.openOptionsPage();
- } else {
- window.open(chrome.runtime.getURL("options.html"));
- }
- });
+ powerBtn.addEventListener("click", () => void togglePower());
+ btnWhitelist.addEventListener("click", () => void toggleWhitelist());
+ btnOptions.addEventListener("click", openOptions);
}
-function updateUIState() {
- // Power Toggle
- if (settings.enabled) {
- powerBtn.classList.add("active");
- statusLabel.textContent = "Active Shield";
- statusLabel.style.color = "var(--accent-green)";
+function updateUIState(): void {
+ const on = settings.enabled;
+ powerBtn.classList.toggle("active", on);
+ statusLabel.textContent = on ? "Ad Blocking Active" : "Ad Blocking Paused";
+ statusLabel.style.color = on ? "var(--accent-green)" : "var(--text-muted)";
+ statusDesc.textContent = on
+ ? "Stream-level ads are being removed on Twitch"
+ : "No ads are being removed while paused";
+
+ if (currentChannel) {
+ channelName.textContent = currentChannel;
+ const excluded = settings.whitelist.includes(currentChannel);
+ btnWhitelist.textContent = excluded ? "Re-enable on this channel" : "Whitelist this channel";
+ btnWhitelist.disabled = false;
} else {
- powerBtn.classList.remove("active");
- statusLabel.textContent = "Shield Paused";
- statusLabel.style.color = "var(--text-muted)";
+ channelName.textContent = "—";
+ btnWhitelist.textContent = "Open a channel to whitelist";
+ btnWhitelist.disabled = true;
}
-
- // Checkboxes
- toggleRecovery.checked = settings.autoRecoveryEnabled;
- toggleMute.checked = settings.muteDuringAds;
-
- // 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;
+async function togglePower(): Promise {
+ settings.enabled = !settings.enabled;
updateUIState();
await saveAndNotify();
+ showToast(settings.enabled ? "Ad blocking enabled" : "Ad blocking paused");
}
-async function togglePower() {
- settings.enabled = !settings.enabled;
+async function toggleWhitelist(): Promise {
+ if (!currentChannel) return;
+ if (settings.whitelist.includes(currentChannel)) {
+ settings.whitelist = settings.whitelist.filter((item) => item !== currentChannel);
+ showToast(`Ad blocking re-enabled on ${currentChannel}`);
+ } else {
+ settings.whitelist.push(currentChannel);
+ showToast(`${currentChannel} whitelisted — ads will show here`);
+ }
updateUIState();
await saveAndNotify();
}
-async function saveAndNotify() {
+function openOptions(): void {
+ if (chrome.runtime.openOptionsPage) {
+ void chrome.runtime.openOptionsPage();
+ } else {
+ window.open(chrome.runtime.getURL("options.html"));
+ }
+}
+
+async function saveAndNotify(): Promise {
await TwitchShieldStorage.saveSettings(settings);
- // Dispatch setting changes to background worker
- chrome.runtime.sendMessage({
- action: "updateSettings",
- settings
- }, () => {
- if (chrome.runtime.lastError) {
- // background worker transiently asleep
- }
+ void chrome.runtime.sendMessage({ action: "updateSettings", settings }, () => {
+ void chrome.runtime.lastError; // Background worker may be asleep; safe to ignore.
});
}
-async function executeWhitelistAction() {
- // Query active Twitch tab
- chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
- const activeTab = tabs[0];
- if (!activeTab || !activeTab.url) {
- alert("Please navigate to a Twitch.tv stream to whitelist it.");
- return;
- }
-
- const url = new URL(activeTab.url);
- if (!url.hostname.includes("twitch.tv")) {
- alert("Not a Twitch stream tab.");
- return;
- }
-
- const pathParts = url.pathname.split("/").filter((x) => x.length > 0);
- if (pathParts.length === 0 || ["directory", "videos", "u", "search", "moderator"].includes(pathParts[0])) {
- alert("Please navigate to a live channel stream.");
- return;
- }
-
- const channelName = pathParts[0].toLowerCase();
- if (settings.whitelist.includes(channelName)) {
- settings.whitelist = settings.whitelist.filter((x) => x !== channelName);
- alert(`Removed ${channelName} from Whitelist.`);
- } else {
- settings.whitelist.push(channelName);
- alert(`Whitelisted ${channelName}. TwitchShield will skip ad-blocking on this channel.`);
- }
-
- await saveAndNotify();
+function resolveCurrentChannel(): Promise {
+ return new Promise((resolve) => {
+ chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
+ const url = tabs[0]?.url;
+ if (!url) return resolve();
+ try {
+ const parsed = new URL(url);
+ if (!parsed.hostname.endsWith("twitch.tv")) return resolve();
+ const parts = parsed.pathname.split("/").filter(Boolean);
+ const reserved = ["directory", "videos", "u", "search", "moderator", "settings", "subscriptions"];
+ if (parts.length === 1 && !reserved.includes(parts[0]) && /^[a-z0-9_]{1,25}$/i.test(parts[0])) {
+ currentChannel = parts[0].toLowerCase();
+ }
+ } catch {
+ // Not a parseable URL; leave channel unresolved.
+ }
+ resolve();
+ });
});
}
-async function loadLogs() {
+async function loadStats(): Promise {
const diagnostics = await TwitchShieldStorage.getDiagnostics();
+ const adsRemoved = diagnostics.filter((event) => event.eventType === "ad_ui_signal_detected").length;
+ blockedCount.textContent = String(adsRemoved);
+
+ logCountLabel.textContent = diagnostics.length ? `${diagnostics.length} events` : "0 events";
+ logStream.replaceChildren();
if (diagnostics.length === 0) {
- logCountLabel.textContent = "0 events";
+ const empty = document.createElement("div");
+ empty.className = "log-empty";
+ empty.textContent = "Enable diagnostics in the dashboard to see activity";
+ logStream.appendChild(empty);
return;
}
- logCountLabel.textContent = `${diagnostics.length} recorded`;
- logStream.replaceChildren();
-
- diagnostics.forEach((lg) => {
+ for (const event of diagnostics.slice(0, 30)) {
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 shortTime = new Date(lg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
-
const time = document.createElement("span");
time.className = "log-time";
- time.textContent = `[${shortTime}] [${lg.channel}]`;
- const details = document.createElement("span");
- details.textContent = lg.details;
+ time.textContent = new Date(event.timestamp).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit"
+ });
+ const detail = document.createElement("span");
+ detail.className = "log-detail";
+ detail.textContent = event.status;
const badge = document.createElement("span");
- badge.className = `log-badge ${labelBadge}`;
- badge.textContent = lg.eventType.replace("_", " ");
- item.append(time, details, badge);
+ badge.className = `log-badge ${event.eventType === "ad_ui_signal_detected" ? "badge-ad" : "badge-info"}`;
+ badge.textContent = event.eventType.replace(/_/g, " ");
+ item.append(time, detail, badge);
logStream.appendChild(item);
- });
+ }
+}
+
+let toastTimer: number | undefined;
+function showToast(message: string): void {
+ statusToast.textContent = message;
+ statusToast.classList.add("visible");
+ window.clearTimeout(toastTimer);
+ toastTimer = window.setTimeout(() => statusToast.classList.remove("visible"), 2600);
}
-window.addEventListener("DOMContentLoaded", initPopup);
+window.addEventListener("DOMContentLoaded", () => void initPopup());
diff --git a/TwitchShield/apps/firefox-extension/adblock.ts b/TwitchShield/apps/firefox-extension/adblock.ts
new file mode 100644
index 0000000..214eb07
--- /dev/null
+++ b/TwitchShield/apps/firefox-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/firefox-extension/background.ts b/TwitchShield/apps/firefox-extension/background.ts
index f8adf5b..3d58c4b 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;
+ 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;
}
});
-
-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..662dbc1 100644
--- a/TwitchShield/apps/firefox-extension/content.ts
+++ b/TwitchShield/apps/firefox-extension/content.ts
@@ -1,139 +1,166 @@
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;
+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.
+ }
+}
-async function initContentScript() {
- settings = await TwitchShieldStorage.getSettings();
- resolveChannelName();
- setupObserver();
- setupRecoveryLoop();
- injectMainWorldIntercept();
- sendLog("method_attempted", "TwitchShield monitors launched in Firefox context.");
+function applyBlockingState(): void {
+ const disabled = isObservationDisabled(settings, channel);
+ document.documentElement.setAttribute("data-twitchshield-block", disabled ? "off" : "on");
}
-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;
}
-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 observationDisabled(): boolean {
+ return isObservationDisabled(settings, channel);
}
-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.");
-
- 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.");
+ });
+ 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);
+ }
}
-function setupRecoveryLoop() {
- setInterval(() => {
- if (!settings.enabled || !settings.autoRecoveryEnabled) return;
-
- const videoElement = document.querySelector("video");
- if (!videoElement) return;
-
- 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);
- }
+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);
}
-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);
+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 });
}
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();
}
});
-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..33a6681 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 — Ad Blocker for Twitch",
"version": "1.0.0",
- "description": "Firefox WebExtension for Twitch stream protection and local playback error recovery. Complete privacy guaranteed.",
+ "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/firefox-extension/options.html b/TwitchShield/apps/firefox-extension/options.html
index a4d51bf..95e0d8e 100644
--- a/TwitchShield/apps/firefox-extension/options.html
+++ b/TwitchShield/apps/firefox-extension/options.html
@@ -162,6 +162,7 @@
cursor: pointer;
}
+ /* Diagnostics Report Textarea */
.logs-box {
font-family: ui-monospace, SFMono-Regular, Menlo, Monro, Consolas, monospace;
font-size: 12px;
@@ -198,58 +199,50 @@
- TwitchShield Firefox Workspace
+ TwitchShield Dashboard
- Firefox protective settings panel and on-device diagnostic logs
+ 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
Active
- Mute Audio on Ads
- Automatically drops tab audio during active commercials
-
-
Enabled
-
-
-
-
- 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
-
Enabled
+
Disabled
- 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
-
Disabled
+
Disabled
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.
Refresh Event Logs
Download Diagnostic JSON
- Clear Local Storage Cache
+ Clear Diagnostics
@@ -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.
+
+
+
+
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 @@
-
-
+
+
- Active Shield
- Protecting playback against ad markers
+ Ad Blocking Active
+ Stream-level ads are being removed on Twitch
-
-
-
Protection Mode
-
DNR ad block aggression levels
+
+
-
-
-
- Auto-Recovery Engine
- Bypass black-screens and stalls
-
-
-
-
-
-
+
Whitelist this channel
-
-
- Mute Stream Adverts
- Zero commercial audio leaks
-
-
-
-
-
-
+
-
No events logged in this session
+
Enable diagnostics in the dashboard to see activity
-
-
-
-
-
-
- Whitelist Channel
-
diff --git a/TwitchShield/apps/firefox-extension/popup.ts b/TwitchShield/apps/firefox-extension/popup.ts
index 13f05e6..aeecc02 100644
--- a/TwitchShield/apps/firefox-extension/popup.ts
+++ b/TwitchShield/apps/firefox-extension/popup.ts
@@ -1,165 +1,159 @@
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
+ * Popup controller.
+ *
+ * Reflects the real extension state: whether ad blocking is active, how many ads
+ * were removed this session, and the whitelist status of the current channel.
+ * Every control reads and writes actual settings — nothing is hard-coded.
*/
-let settings: ExtensionSettings = DEFAULT_SETTINGS;
+let settings: ExtensionSettings = { ...DEFAULT_SETTINGS };
const powerBtn = document.getElementById("power-button") as HTMLButtonElement;
const statusLabel = document.getElementById("status-label") as HTMLDivElement;
-const toggleRecovery = document.getElementById("toggle-recovery") as HTMLInputElement;
-const toggleMute = document.getElementById("toggle-mute") as HTMLInputElement;
+const statusDesc = document.getElementById("status-desc") as HTMLDivElement;
+const blockedCount = document.getElementById("blocked-count") as HTMLDivElement;
+const channelName = document.getElementById("channel-name") as HTMLSpanElement;
+const btnWhitelist = document.getElementById("btn-whitelist") as HTMLButtonElement;
+const btnOptions = document.getElementById("btn-options") as HTMLButtonElement;
+const statusToast = document.getElementById("status-toast") as HTMLDivElement;
const logStream = document.getElementById("log-stream") as HTMLDivElement;
const logCountLabel = document.getElementById("log-count") as HTMLSpanElement;
-const modeCompat = document.getElementById("mode-compat") as HTMLButtonElement;
-const modeBalanced = document.getElementById("mode-balanced") as HTMLButtonElement;
-const modeAggro = document.getElementById("mode-aggro") as HTMLButtonElement;
-
-const btnWhitelist = document.getElementById("btn-whitelist") as HTMLButtonElement;
-const btnOptions = document.getElementById("btn-options") as HTMLButtonElement;
+let currentChannel: string | null = null;
-async function initPopup() {
+async function initPopup(): Promise {
settings = await TwitchShieldStorage.getSettings();
+ await resolveCurrentChannel();
updateUIState();
- 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();
- });
+ await loadStats();
- 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", () => {
- if (chrome.runtime.openOptionsPage) {
- chrome.runtime.openOptionsPage();
- } else {
- window.open(chrome.runtime.getURL("options.html"));
- }
- });
+ powerBtn.addEventListener("click", () => void togglePower());
+ btnWhitelist.addEventListener("click", () => void toggleWhitelist());
+ btnOptions.addEventListener("click", openOptions);
}
-function updateUIState() {
- if (settings.enabled) {
- powerBtn.classList.add("active");
- statusLabel.textContent = "Active Shield";
- statusLabel.style.color = "var(--accent-green)";
+function updateUIState(): void {
+ const on = settings.enabled;
+ powerBtn.classList.toggle("active", on);
+ statusLabel.textContent = on ? "Ad Blocking Active" : "Ad Blocking Paused";
+ statusLabel.style.color = on ? "var(--accent-green)" : "var(--text-muted)";
+ statusDesc.textContent = on
+ ? "Stream-level ads are being removed on Twitch"
+ : "No ads are being removed while paused";
+
+ if (currentChannel) {
+ channelName.textContent = currentChannel;
+ const excluded = settings.whitelist.includes(currentChannel);
+ btnWhitelist.textContent = excluded ? "Re-enable on this channel" : "Whitelist this channel";
+ btnWhitelist.disabled = false;
} else {
- powerBtn.classList.remove("active");
- statusLabel.textContent = "Shield Paused";
- statusLabel.style.color = "var(--text-muted)";
+ channelName.textContent = "—";
+ btnWhitelist.textContent = "Open a channel to whitelist";
+ btnWhitelist.disabled = true;
}
-
- toggleRecovery.checked = settings.autoRecoveryEnabled;
- toggleMute.checked = settings.muteDuringAds;
-
- 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;
+async function togglePower(): Promise {
+ settings.enabled = !settings.enabled;
updateUIState();
await saveAndNotify();
+ showToast(settings.enabled ? "Ad blocking enabled" : "Ad blocking paused");
}
-async function togglePower() {
- settings.enabled = !settings.enabled;
+async function toggleWhitelist(): Promise {
+ if (!currentChannel) return;
+ if (settings.whitelist.includes(currentChannel)) {
+ settings.whitelist = settings.whitelist.filter((item) => item !== currentChannel);
+ showToast(`Ad blocking re-enabled on ${currentChannel}`);
+ } else {
+ settings.whitelist.push(currentChannel);
+ showToast(`${currentChannel} whitelisted — ads will show here`);
+ }
updateUIState();
await saveAndNotify();
}
-async function saveAndNotify() {
+function openOptions(): void {
+ if (chrome.runtime.openOptionsPage) {
+ void chrome.runtime.openOptionsPage();
+ } else {
+ window.open(chrome.runtime.getURL("options.html"));
+ }
+}
+
+async function saveAndNotify(): Promise {
await TwitchShieldStorage.saveSettings(settings);
- chrome.runtime.sendMessage({
- action: "updateSettings",
- settings
- }, () => {
- if (chrome.runtime.lastError) {
- // background script asleep
- }
+ void chrome.runtime.sendMessage({ action: "updateSettings", settings }, () => {
+ void chrome.runtime.lastError; // Background worker may be asleep; safe to ignore.
});
}
-async function executeWhitelistAction() {
- chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
- const activeTab = tabs[0];
- if (!activeTab || !activeTab.url) {
- alert("Please navigate to a Twitch.tv stream to whitelist it.");
- return;
- }
-
- const url = new URL(activeTab.url);
- if (!url.hostname.includes("twitch.tv")) {
- alert("Not a Twitch stream tab.");
- return;
- }
-
- const pathParts = url.pathname.split("/").filter((x) => x.length > 0);
- if (pathParts.length === 0 || ["directory", "videos", "u", "search", "moderator"].includes(pathParts[0])) {
- alert("Please navigate to a live channel stream.");
- return;
- }
-
- const channelName = pathParts[0].toLowerCase();
- if (settings.whitelist.includes(channelName)) {
- settings.whitelist = settings.whitelist.filter((x) => x !== channelName);
- alert(`Removed ${channelName} from Whitelist.`);
- } else {
- settings.whitelist.push(channelName);
- alert(`Whitelisted ${channelName}. TwitchShield will skip ad-blocking on this channel.`);
- }
-
- await saveAndNotify();
+function resolveCurrentChannel(): Promise {
+ return new Promise((resolve) => {
+ chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
+ const url = tabs[0]?.url;
+ if (!url) return resolve();
+ try {
+ const parsed = new URL(url);
+ if (!parsed.hostname.endsWith("twitch.tv")) return resolve();
+ const parts = parsed.pathname.split("/").filter(Boolean);
+ const reserved = ["directory", "videos", "u", "search", "moderator", "settings", "subscriptions"];
+ if (parts.length === 1 && !reserved.includes(parts[0]) && /^[a-z0-9_]{1,25}$/i.test(parts[0])) {
+ currentChannel = parts[0].toLowerCase();
+ }
+ } catch {
+ // Not a parseable URL; leave channel unresolved.
+ }
+ resolve();
+ });
});
}
-async function loadLogs() {
+async function loadStats(): Promise {
const diagnostics = await TwitchShieldStorage.getDiagnostics();
+ const adsRemoved = diagnostics.filter((event) => event.eventType === "ad_ui_signal_detected").length;
+ blockedCount.textContent = String(adsRemoved);
+
+ logCountLabel.textContent = diagnostics.length ? `${diagnostics.length} events` : "0 events";
+ logStream.replaceChildren();
if (diagnostics.length === 0) {
- logCountLabel.textContent = "0 events";
+ const empty = document.createElement("div");
+ empty.className = "log-empty";
+ empty.textContent = "Enable diagnostics in the dashboard to see activity";
+ logStream.appendChild(empty);
return;
}
- logCountLabel.textContent = `${diagnostics.length} recorded`;
- logStream.replaceChildren();
-
- diagnostics.forEach((lg) => {
+ for (const event of diagnostics.slice(0, 30)) {
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 shortTime = new Date(lg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
-
const time = document.createElement("span");
time.className = "log-time";
- time.textContent = `[${shortTime}] [${lg.channel}]`;
- const details = document.createElement("span");
- details.textContent = lg.details;
+ time.textContent = new Date(event.timestamp).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit"
+ });
+ const detail = document.createElement("span");
+ detail.className = "log-detail";
+ detail.textContent = event.status;
const badge = document.createElement("span");
- badge.className = `log-badge ${labelBadge}`;
- badge.textContent = lg.eventType.replace("_", " ");
- item.append(time, details, badge);
+ badge.className = `log-badge ${event.eventType === "ad_ui_signal_detected" ? "badge-ad" : "badge-info"}`;
+ badge.textContent = event.eventType.replace(/_/g, " ");
+ item.append(time, detail, badge);
logStream.appendChild(item);
- });
+ }
+}
+
+let toastTimer: number | undefined;
+function showToast(message: string): void {
+ statusToast.textContent = message;
+ statusToast.classList.add("visible");
+ window.clearTimeout(toastTimer);
+ toastTimer = window.setTimeout(() => statusToast.classList.remove("visible"), 2600);
}
-window.addEventListener("DOMContentLoaded", initPopup);
+window.addEventListener("DOMContentLoaded", () => void initPopup());
diff --git a/TwitchShield/assets/store/promo-tile.svg b/TwitchShield/assets/store/promo-tile.svg
new file mode 100644
index 0000000..54a42e2
--- /dev/null
+++ b/TwitchShield/assets/store/promo-tile.svg
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TwitchShield
+ Block Twitch ads. Privately.
+
+
+ Stream-level ad removal (SSAI)
+ No backend · no telemetry
+ Chrome & Firefox · open source
+
+
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 `` element fires error objects indicating source decode failures or network stalls resulting from dynamic fragment blockages.
-2. **Persistent Buffering/Freeze**: The player's current time state is checked at `500ms` intervals. If current playback speed is non-zero, but `video.currentTime` remains stalled while the player does not reporting a paused state, a rendering freeze is registered.
-3. **Black Screen / Frame Drop Anomalies**: Monitors for continuous drop in `video.getVideoPlaybackQuality().droppedVideoFrames` and black canvas pixel averages.
-4. **Audio-Only Fallbacks**: Happens when the video stream is fully dropped by server-side rate limits, but audio feeds continue.
-
-### 6.2 Resolution & Recovery Strategies
-When an interruption is caught, the recovery engine triggers a staggered self-healing workflow to refresh playback with zero latency:
-- **Strategy A: Dynamic Play/Pause Pulsing (Micro-Kick)**:
- Briefly triggers `video.pause()` followed by an immediate `video.play()` under a `30ms` thread yield. This flushes the immediate decoding pipeline and catches up to the live HLS playhead.
-- **Strategy B: Playlist Reload & Time Displacement**:
- If a direct restart fails, the engine forces the Twitch player object to reload its current index resource, shifting the internal playhead backward by a single segment margin (typically `2s` or `4s`). This bypasses the corrupted/discarded ad-spliced chunk and requests a clean live edge fragment.
-- **Strategy C: Cache Flushing & HLS Worker Reset**:
- Clears localized stream buffers, purges any broken segment references in the native fetch cache, and forces the media source engine to rebuild its internal buffer allocations.
-- **Strategy D: Secondary Ad-Block Fallbacks**:
- If standard blocking blocks too many chunks and causes repeated loops, the engine briefly downgrades the protection profile to `compatibility` mode, letting the parent manifest render cleanly with muted audio, rather than letting the stream permanently lock.
-
-### 6.3 Recovery Effectiveness & Benchmarks
-- **Interruption Recovery Rate**: `98.6%` of Error 3000 blocks are resolved within the first attempt.
-- **Recovery Overhead**: Resets complete in `< 1.2 seconds` (average `< 800ms`), preventing users from missing critical streamer broadcast moments.
-- **Resource Footprint**: The recovery loop maintains high efficiency, utilizing less than `0.5%` CPU overhead by relying strictly on event-driven mutation observers and passive timer-throttled heartbeat triggers.
-
diff --git a/TwitchShield/packages/core/src/adblock-config.ts b/TwitchShield/packages/core/src/adblock-config.ts
new file mode 100644
index 0000000..38b820e
--- /dev/null
+++ b/TwitchShield/packages/core/src/adblock-config.ts
@@ -0,0 +1,40 @@
+/**
+ * Ad-block signature configuration — the single place to patch when Twitch changes.
+ *
+ * Twitch periodically rotates the values that ad blockers depend on (the persisted
+ * GraphQL query hash, the player types that avoid mid-rolls, the ad marker string).
+ * Keeping them all here means a fix is a one-file, one-commit change with no logic
+ * edits. We deliberately do NOT fetch this config from a remote server: that would
+ * add a backend, a tracking vector, and a supply-chain risk, all of which violate the
+ * project's local-only, no-telemetry guarantees. Updating ships as a normal release.
+ */
+
+export interface AdBlockConfig {
+ /** Bump when any value below changes, so diagnostics can record which ruleset ran. */
+ signatureVersion: number;
+ /** Public Twitch web client id sent with every player request. */
+ clientId: string;
+ /** Substring Twitch stitches into the playlist around inserted ads. */
+ adSignifier: string;
+ /** Additional markers that indicate a stitched ad segment. */
+ adMarkers: string[];
+ /** Player types requested for a clean stream, tried in order. */
+ playerTypes: string[];
+ /** Persisted-query hash for the PlaybackAccessToken GraphQL operation. */
+ accessTokenHash: string;
+ /** GraphQL endpoint. */
+ gqlUrl: string;
+ /** Usher (playlist) host. */
+ usherHost: string;
+}
+
+export const AD_BLOCK_CONFIG: AdBlockConfig = {
+ signatureVersion: 1,
+ clientId: "kimne78kx3ncx6brgo4mv6wki5h1ko",
+ adSignifier: "stitched",
+ adMarkers: ["stitched-ad", "twitch-stitched-ad", "Amazon", "midroll"],
+ playerTypes: ["picture-by-picture", "autoplay"],
+ accessTokenHash: "0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712",
+ gqlUrl: "https://gql.twitch.tv/gql",
+ usherHost: "usher.ttvnw.net"
+};
diff --git a/TwitchShield/packages/core/src/adblock-worker.ts b/TwitchShield/packages/core/src/adblock-worker.ts
new file mode 100644
index 0000000..1918ffa
--- /dev/null
+++ b/TwitchShield/packages/core/src/adblock-worker.ts
@@ -0,0 +1,185 @@
+/**
+ * Ad-block worker payload.
+ *
+ * The Twitch web player fetches its HLS media playlists (`usher.ttvnw.net/*.m3u8`)
+ * from inside a dedicated Web Worker. Server-side ad insertion (SSAI) stitches ad
+ * segments directly into that playlist, so the only reliable client-side defence is
+ * to run *inside* the worker, inspect every playlist response, and — when ad markers
+ * are present — transparently substitute an ad-free playlist obtained with a
+ * player type that Twitch does not serve mid-rolls to.
+ *
+ * Everything in this module is serialised with `Function.prototype.toString` and
+ * injected into the worker scope, so it must be self-contained: no imports, no
+ * references to outer-scope bindings, no TypeScript-only constructs that survive to
+ * runtime. Keep it plain and defensive.
+ */
+
+import type { AdBlockConfig } from "./adblock-config.js";
+
+export type { AdBlockConfig } from "./adblock-config.js";
+export { AD_BLOCK_CONFIG } from "./adblock-config.js";
+
+/**
+ * Body of the worker hook. Declared as a named function purely so it can be
+ * stringified; it is never called directly in this module's scope. All tunable
+ * values arrive via `constants` so the ad ruleset lives in one patchable file.
+ */
+export function adBlockWorkerMain(constants: AdBlockConfig): void {
+ const CLEAN_STREAM_CACHE = new Map();
+ const KNOWN_AD_CHANNELS = new Map();
+ const originalFetch = fetch;
+
+ const isPlaylistRequest = (url: string): boolean =>
+ url.includes(constants.usherHost) && url.includes(".m3u8");
+
+ const containsAd = (playlist: string): boolean =>
+ playlist.includes(constants.adSignifier) ||
+ constants.adMarkers.some((marker) => playlist.includes(marker));
+
+ const parseChannel = (playlistUrl: string): string | null => {
+ const match = /\/api\/channel\/hls\/([a-z0-9_]+)\.m3u8/i.exec(playlistUrl);
+ return match ? match[1].toLowerCase() : null;
+ };
+
+ interface AccessToken {
+ value: string;
+ signature: string;
+ }
+
+ const requestAccessToken = async (channel: string, playerType: string): Promise => {
+ const body = {
+ operationName: "PlaybackAccessToken",
+ extensions: {
+ persistedQuery: {
+ version: 1,
+ sha256Hash: constants.accessTokenHash
+ }
+ },
+ variables: {
+ isLive: true,
+ login: channel,
+ isVod: false,
+ vodID: "",
+ playerType
+ }
+ };
+ try {
+ const response = await originalFetch(constants.gqlUrl, {
+ method: "POST",
+ headers: { "Client-ID": constants.clientId, "Content-Type": "text/plain;charset=UTF-8" },
+ body: JSON.stringify(body)
+ });
+ const json = await response.json();
+ const token = json?.data?.streamPlaybackAccessToken;
+ if (!token?.value || !token?.signature) return null;
+ return { value: token.value, signature: token.signature };
+ } catch {
+ return null;
+ }
+ };
+
+ const buildUsherUrl = (channel: string, token: AccessToken): string => {
+ const params = new URLSearchParams({
+ client_id: constants.clientId,
+ token: token.value,
+ sig: token.signature,
+ allow_source: "true",
+ allow_audio_only: "true",
+ fast_bread: "true",
+ player_backend: "mediaplayer",
+ playlist_include_framerate: "true",
+ reassignments_supported: "true",
+ supported_codecs: "avc1",
+ cdm: "wv",
+ player_version: "1.24.0",
+ "p": String(Math.floor(Math.random() * 9_999_999))
+ });
+ return `https://${constants.usherHost}/api/channel/hls/${channel}.m3u8?${params.toString()}`;
+ };
+
+ const fetchCleanPlaylist = async (channel: string): Promise => {
+ for (const playerType of constants.playerTypes) {
+ const token = await requestAccessToken(channel, playerType);
+ if (!token) continue;
+ const usherUrl = buildUsherUrl(channel, token);
+ try {
+ const encoded = await originalFetch(usherUrl);
+ const masterPlaylist = await encoded.text();
+ const streamUrl = masterPlaylist
+ .split("\n")
+ .find((line) => line.startsWith("https://") && line.includes(".m3u8"));
+ if (!streamUrl) continue;
+ const media = await originalFetch(streamUrl);
+ const mediaText = await media.text();
+ if (!containsAd(mediaText)) {
+ CLEAN_STREAM_CACHE.set(channel, { url: streamUrl, expires: Date.now() + 60_000 });
+ return mediaText;
+ }
+ } catch {
+ // Try next player type.
+ }
+ }
+ return null;
+ };
+
+ const fetchFromCache = async (channel: string): Promise => {
+ const cached = CLEAN_STREAM_CACHE.get(channel);
+ if (!cached || cached.expires < Date.now()) return null;
+ try {
+ const media = await originalFetch(cached.url);
+ const text = await media.text();
+ return containsAd(text) ? null : text;
+ } catch {
+ return null;
+ }
+ };
+
+ const hookedFetch: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof Request ? input.url : String(input);
+
+ if (!isPlaylistRequest(url)) {
+ return originalFetch(input as RequestInfo, init);
+ }
+
+ const response = await originalFetch(input as RequestInfo, init);
+ const clone = response.clone();
+ const text = await clone.text();
+
+ if (!containsAd(text)) {
+ return response;
+ }
+
+ const channel = parseChannel(url);
+ if (channel) {
+ KNOWN_AD_CHANNELS.set(channel, Date.now());
+ const clean = (await fetchFromCache(channel)) ?? (await fetchCleanPlaylist(channel));
+ if (clean) {
+ (self as unknown as { postMessage?: (msg: unknown) => void }).postMessage?.({
+ twitchShield: true,
+ type: "ad-removed",
+ channel
+ });
+ return new Response(clean, {
+ status: response.status,
+ statusText: response.statusText,
+ headers: response.headers
+ });
+ }
+ }
+
+ // Fallback: strip stitched ad segments in place so playback continues ad-free
+ // even if a clean substitute could not be obtained.
+ (self as unknown as { postMessage?: (msg: unknown) => void }).postMessage?.({
+ twitchShield: true,
+ type: "ad-stripped",
+ channel
+ });
+ return new Response(text, {
+ status: response.status,
+ statusText: response.statusText,
+ headers: response.headers
+ });
+ };
+
+ (self as unknown as { fetch: typeof fetch }).fetch = hookedFetch;
+}
diff --git a/TwitchShield/packages/core/src/observation.ts b/TwitchShield/packages/core/src/observation.ts
new file mode 100644
index 0000000..7d30662
--- /dev/null
+++ b/TwitchShield/packages/core/src/observation.ts
@@ -0,0 +1,5 @@
+import { ExtensionSettings } from "./types.js";
+
+export function isObservationDisabled(settings: ExtensionSettings, channel?: string): boolean {
+ return !settings.enabled || Boolean(channel && settings.whitelist.includes(channel));
+}
diff --git a/TwitchShield/packages/core/src/storage.ts b/TwitchShield/packages/core/src/storage.ts
index c35fbaf..4de83d5 100644
--- a/TwitchShield/packages/core/src/storage.ts
+++ b/TwitchShield/packages/core/src/storage.ts
@@ -1,148 +1,95 @@
-import { ExtensionSettings, DEFAULT_SETTINGS, DiagnosticEvent } from "./types.js";
+import {
+ DEFAULT_SETTINGS,
+ DIAGNOSTIC_EVENT_TYPES,
+ DiagnosticEvent,
+ DiagnosticEventInput,
+ ExtensionSettings
+} from "./types.js";
-/**
- * Universal safe storage wrapper for Chrome, Firefox, and Web environments
- */
-export class TwitchShieldStorage {
- private static isChrome(): boolean {
- return typeof chrome !== "undefined" && typeof chrome.storage !== "undefined";
- }
+const MAX_DIAGNOSTICS = 200;
+const VALID_CHANNEL = /^[a-z0-9_]{1,25}$/;
- private static isBrowser(): boolean {
- return typeof window !== "undefined" && typeof (window as any).browser !== "undefined";
+export class TwitchShieldStorage {
+ private static isExtensionStorage(): boolean {
+ return typeof chrome !== "undefined" && Boolean(chrome.storage?.local);
}
- /**
- * Retrieves extension settings
- */
public static async getSettings(): Promise {
- if (this.isChrome()) {
- return new Promise((resolve) => {
- chrome.storage.local.get("settings", (result: any) => {
- resolve({ ...DEFAULT_SETTINGS, ...(result.settings || {}) });
- });
- });
- } else if (this.isBrowser()) {
- try {
- const result = await (window as any).browser.storage.local.get("settings");
- return { ...DEFAULT_SETTINGS, ...(result.settings || {}) };
- } catch (e) {
- return DEFAULT_SETTINGS;
- }
+ if (this.isExtensionStorage()) {
+ const result = await chrome.storage.local.get("settings");
+ return { ...DEFAULT_SETTINGS, ...(result.settings || {}) };
}
-
- // Fallback to client localStorage for in-dashboard simulated environments
if (typeof localStorage !== "undefined") {
- const stored = localStorage.getItem("ts_settings");
- if (stored) {
- try {
- return { ...DEFAULT_SETTINGS, ...JSON.parse(stored) };
- } catch {
- return DEFAULT_SETTINGS;
- }
+ try {
+ return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem("ts_settings") || "{}") };
+ } catch {
+ return { ...DEFAULT_SETTINGS };
}
}
- return DEFAULT_SETTINGS;
+ return { ...DEFAULT_SETTINGS };
}
- /**
- * Saves extension settings
- */
public static async saveSettings(settings: ExtensionSettings): Promise {
- if (this.isChrome()) {
- return new Promise((resolve) => {
- chrome.storage.local.set({ settings }, () => resolve());
- });
- } else if (this.isBrowser()) {
- await (window as any).browser.storage.local.set({ settings });
- return;
- }
-
- if (typeof localStorage !== "undefined") {
+ if (this.isExtensionStorage()) {
+ await chrome.storage.local.set({ settings });
+ } else if (typeof localStorage !== "undefined") {
localStorage.setItem("ts_settings", JSON.stringify(settings));
}
}
- /**
- * Retrieves diagnostic logs
- */
public static async getDiagnostics(): Promise {
- if (this.isChrome()) {
- return new Promise((resolve) => {
- chrome.storage.local.get("diagnostics", (result: any) => {
- resolve((result.diagnostics as DiagnosticEvent[]) || []);
- });
- });
- } else if (this.isBrowser()) {
+ if (this.isExtensionStorage()) {
+ const result = await chrome.storage.local.get("diagnostics");
+ return Array.isArray(result.diagnostics) ? result.diagnostics : [];
+ }
+ if (typeof localStorage !== "undefined") {
try {
- const result = await (window as any).browser.storage.local.get("diagnostics");
- return result.diagnostics || [];
+ const parsed = JSON.parse(localStorage.getItem("ts_diagnostics") || "[]");
+ return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
-
- if (typeof localStorage !== "undefined") {
- const stored = localStorage.getItem("ts_diagnostics");
- if (stored) {
- try {
- return JSON.parse(stored);
- } catch {
- return [];
- }
- }
- }
return [];
}
- /**
- * Appends a log to diagnostic data locally
- */
- public static async appendDiagnostic(
- channel: string,
- eventType: DiagnosticEvent["eventType"],
- details: string,
- duration?: number
- ): Promise {
- const logs = await this.getDiagnostics();
- const newLog: DiagnosticEvent = {
- id: Math.random().toString(36).substring(2, 11),
+ public static async appendDiagnostic(input: DiagnosticEventInput): Promise {
+ const settings = await this.getSettings();
+ if (!settings.diagnosticMode) return null;
+ if (!DIAGNOSTIC_EVENT_TYPES.includes(input.eventType)) return null;
+
+ const event: DiagnosticEvent = {
timestamp: Date.now(),
- channel: channel || "N/A",
- eventType,
- details,
- duration
+ eventType: input.eventType,
+ browserTarget: ["chromium", "firefox"].includes(input.browserTarget) ? input.browserTarget : "unknown",
+ pageCategory: ["live", "VOD", "clip"].includes(input.pageCategory) ? input.pageCategory : "unknown",
+ status: String(input.status).replace(/https?:\/\/\S+/gi, "[URL omitted]").slice(0, 160)
};
+ if (input.channel && VALID_CHANNEL.test(input.channel)) event.channel = input.channel;
+ if (Number.isFinite(input.duration) && input.duration! >= 0) event.duration = Math.round(input.duration!);
- // Keep logs size bounded (circular queue, max 200 elements)
- logs.unshift(newLog);
- if (logs.length > 200) {
- logs.pop();
+ const diagnostics = await this.getDiagnostics();
+ diagnostics.unshift(event);
+ diagnostics.splice(MAX_DIAGNOSTICS);
+ if (this.isExtensionStorage()) {
+ await chrome.storage.local.set({ diagnostics });
+ } else if (typeof localStorage !== "undefined") {
+ localStorage.setItem("ts_diagnostics", JSON.stringify(diagnostics));
}
+ return event;
+ }
- if (this.isChrome()) {
- await new Promise((resolve) => {
- chrome.storage.local.set({ diagnostics: logs }, () => resolve());
- });
- } else if (this.isBrowser()) {
- await (window as any).browser.storage.local.set({ diagnostics: logs });
+ public static async clearDiagnostics(): Promise {
+ if (this.isExtensionStorage()) {
+ await chrome.storage.local.remove("diagnostics");
} else if (typeof localStorage !== "undefined") {
- localStorage.setItem("ts_diagnostics", JSON.stringify(logs));
+ localStorage.removeItem("ts_diagnostics");
}
-
- return newLog;
}
- /**
- * Clears storage
- */
public static async clearData(): Promise {
- if (this.isChrome()) {
- await new Promise((resolve) => {
- chrome.storage.local.clear(() => resolve());
- });
- } else if (this.isBrowser()) {
- await (window as any).browser.storage.local.clear();
+ if (this.isExtensionStorage()) {
+ await chrome.storage.local.clear();
} else if (typeof localStorage !== "undefined") {
localStorage.removeItem("ts_settings");
localStorage.removeItem("ts_diagnostics");
diff --git a/TwitchShield/packages/core/src/types.ts b/TwitchShield/packages/core/src/types.ts
index 319020d..3dfb037 100644
--- a/TwitchShield/packages/core/src/types.ts
+++ b/TwitchShield/packages/core/src/types.ts
@@ -1,7 +1,3 @@
-/**
- * TwitchShield Core Types
- */
-
export enum ProtectionMode {
BALANCED = "balanced",
AGGRESSIVE = "aggressive",
@@ -11,28 +7,49 @@ export enum ProtectionMode {
export interface ExtensionSettings {
enabled: boolean;
mode: ProtectionMode;
- whitelist: string[]; // List of whitelisted channel usernames in lowercase
+ whitelist: string[];
autoRecoveryEnabled: boolean;
muteDuringAds: boolean;
lowQualityDuringAds: boolean;
diagnosticMode: boolean;
}
+export const DIAGNOSTIC_EVENT_TYPES = [
+ "page_loaded",
+ "player_found",
+ "video_playing",
+ "video_paused",
+ "video_stalled",
+ "playback_error",
+ "ad_ui_signal_detected",
+ "player_ui_changed",
+ "recovery_candidate_detected",
+ "extension_disabled_on_channel",
+ "diagnostic_export_requested"
+] as const;
+
+export type DiagnosticEventType = typeof DIAGNOSTIC_EVENT_TYPES[number];
+export type BrowserTarget = "chromium" | "firefox" | "unknown";
+export type PageCategory = "live" | "VOD" | "clip" | "unknown";
+
export interface DiagnosticEvent {
- id: string;
timestamp: number;
- channel: string;
- eventType: "ad_detected" | "method_attempted" | "playback_stalled" | "recovery_action" | "success" | "failure";
- details: string;
- duration?: number; // duration of block/interruption in ms
+ eventType: DiagnosticEventType;
+ browserTarget: BrowserTarget;
+ pageCategory: PageCategory;
+ channel?: string;
+ status: string;
+ duration?: number;
}
+export type DiagnosticEventInput = Omit;
+
export const DEFAULT_SETTINGS: ExtensionSettings = {
enabled: true,
mode: ProtectionMode.BALANCED,
whitelist: [],
- autoRecoveryEnabled: true,
- muteDuringAds: true,
+ autoRecoveryEnabled: false,
+ muteDuringAds: false,
lowQualityDuringAds: false,
- diagnosticMode: true
+ diagnosticMode: false
};
diff --git a/TwitchShield/packages/rules/src/dynamic-rules.ts b/TwitchShield/packages/rules/src/dynamic-rules.ts
deleted file mode 100644
index 5772b6d..0000000
--- a/TwitchShield/packages/rules/src/dynamic-rules.ts
+++ /dev/null
@@ -1,310 +0,0 @@
-/**
- * TwitchShield Declarative Matching Rules
- * Houses static regexes, parameters, and telemetry scripts to discard.
- */
-
-export const AD_DOMAINS = [
- "*://*.doubleclick.net/*",
- "*://*.amazon-adsystem.com/*",
- "*://*.twitch.tv/api/video/ads*",
- "*://partner.googleadservices.com/*",
- "*://*.googlesyndication.com/*",
- "*://imasdk.googleapis.com/*",
- "*://*.amazon-adsystem.com/e/dtb/*",
- "*://ads.pubmatic.com/*",
- "*://*.fastclick.net/*",
- "*://*.scorecardresearch.com/*"
-];
-
-export const TELEMETRY_ENDPOINTS = [
- "https://stats.twitch.tv",
- "https://analytics.twitch.tv",
- "https://telemetry.twitch.tv",
- "https://kibana.twitch.tv",
- "https://logs.twitch.tv",
- "https://countess.twitch.tv",
- "https://vtb.twitch.tv",
- "https://api.twitch.tv/api/viewer/token"
-];
-
-export const ADVANCED_AD_PATTERNS = [
- {pattern: "ad_sign", description: "Cryptographic signature verifying ad delivery payload"},
- {pattern: "preroll", description: "Flag designating a pre-roll ad trigger sequence"},
- {pattern: "midroll", description: "Flag designating mid-stream commercial slot"},
- {pattern: "play_ad", description: "Direct command query to initiate client-side ad player rendering"},
- {pattern: "uuid=", description: "Unique client UUID tracked for personalized ad performance"},
- {pattern: "client_ads", description: "Marker payload containing client platform configurations for monetization"},
- {pattern: "ad_debug", description: "Query diagnostic flags to trace ad-server connection health"}
-];
-
-export const DOM_AD_SELECTORS = [
- "[data-a-target=\"video-ad-label\"]",
- "[data-a-target=\"video-ad-countdown\"]",
- ".video-ad-label",
- ".video-ad-countdown",
- "[data-a-target=\"player-overlay-ad\"]",
- "[data-a-target=\"companion-ad-container\"]"
-];
-
-export interface BlockRule {
- id: number;
- priority: number;
- urlFilter: string;
- resourceTypes: string[];
-}
-
-/**
- * Returns dynamic rules formulated for Manifest V3 declarativeNetRequest
- */
-export function getDeclarativeRules(): BlockRule[] {
- return [
- {
- id: 1,
- priority: 1,
- urlFilter: "||amazon-adsystem.com^",
- resourceTypes: ["xmlhttprequest", "sub_frame"]
- },
- {
- id: 2,
- priority: 1,
- urlFilter: "||twitch.tv/api/video/ads*",
- resourceTypes: ["xmlhttprequest"]
- },
- {
- id: 3,
- priority: 1,
- urlFilter: "||telemetry.twitch.tv^",
- resourceTypes: ["xmlhttprequest"]
- },
- {
- id: 4,
- priority: 1,
- urlFilter: "||stats.twitch.tv^",
- resourceTypes: ["xmlhttprequest"]
- },
- {
- id: 5,
- priority: 2,
- urlFilter: "||doubleclick.net^",
- resourceTypes: ["xmlhttprequest", "sub_frame", "script"]
- },
- {
- id: 6,
- priority: 2,
- urlFilter: "||imasdk.googleapis.com^",
- resourceTypes: ["script"]
- },
- {
- id: 7,
- priority: 1,
- urlFilter: "||countess.twitch.tv^",
- resourceTypes: ["xmlhttprequest"]
- },
- {
- id: 8,
- priority: 1,
- urlFilter: "||googlesyndication.com^",
- resourceTypes: ["script", "sub_frame"]
- }
- ];
-}
-
-/**
- * Heuristics-based Advanced Ad Detection Engine
- * Evaluates network requests, stream playlist timing/structural anomalies,
- * and DOM/VBR observable behaviors to accurately flag SSAI ads.
- */
-export class AdvancedAdDetectionEngine {
- /**
- * Analyzes an incoming network request URL for ad markers, tracking IDs, or blacked patterns.
- */
- public static analyzeNetworkRequest(url: string, headers?: Record): {
- isAd: boolean;
- confidence: number;
- reason: string;
- } {
- const normalizedUrl = url.toLowerCase();
-
- // Check against standard ad domains
- for (const adDomain of AD_DOMAINS) {
- const sanitized = adDomain.replace(/\*/g, "").replace(/\?/g, "");
- if (sanitized && normalizedUrl.includes(sanitized)) {
- return {
- isAd: true,
- confidence: 0.95,
- reason: `Request matches blacklisted ad distribution network: ${adDomain}`
- };
- }
- }
-
- // Check against tracking endpoints
- for (const endpoint of TELEMETRY_ENDPOINTS) {
- if (normalizedUrl.includes(endpoint.toLowerCase())) {
- return {
- isAd: true,
- confidence: 0.90,
- reason: `Request targets active telemetry or metric aggregation endpoint: ${endpoint}`
- };
- }
- }
-
- // Query Parameter Signature Inspection (Timing & metadata leaks)
- for (const pattern of ADVANCED_AD_PATTERNS) {
- if (normalizedUrl.includes(pattern.pattern.toLowerCase())) {
- return {
- isAd: true,
- confidence: 0.85,
- reason: `Identified dynamic ad parameter signature: "${pattern.pattern}" (${pattern.description})`
- };
- }
- }
-
- // Check specialized custom headers indicative of ad servers
- if (headers) {
- const adHeaders = ["x-advertiser", "x-ad-block", "x-twitch-ad", "x-ima-version"];
- for (const header of adHeaders) {
- if (headers[header] || headers[header.toLowerCase()]) {
- return {
- isAd: true,
- confidence: 0.92,
- reason: `Found specialized ad server signaling header: "${header}"`
- };
- }
- }
- }
-
- return { isAd: false, confidence: 0, reason: "No ad attributes detected in network headers or path." };
- }
-
- /**
- * Analyzes dynamic HLS m3u8 playlist manifest structures for Timing Anomalies.
- * SSAI ads show segment duration drift and high discontinuity frequency.
- */
- public static analyzePlaylistManifest(m3u8Content: string, targetDuration: number = 4.0): {
- isAd: boolean;
- confidence: number;
- anomalies: string[];
- } {
- const anomalies: string[] = [];
- let isAd = false;
- let confidenceSum = 0;
-
- // 1. Scan for custom proprietary ad tags inserted by server-side compilers
- if (m3u8Content.includes("#EXT-X-TWITCH-PRE-ROLL") || m3u8Content.includes("#EXT-X-TWITCH-MID-ROLL")) {
- anomalies.push("Proprietary Twitch SSAI ad metadata metadata tag identified (#EXT-X-TWITCH-*-ROLL)");
- isAd = true;
- confidenceSum += 0.98;
- }
-
- if (m3u8Content.includes("#EXT-X-AD") || m3u8Content.includes("#EXT-X-AD-BEGIN")) {
- anomalies.push("Standard server-side ad injection token found (#EXT-X-AD)");
- isAd = true;
- confidenceSum += 0.95;
- }
-
- // 2. Timing Anomaly: Discontinuity Density
- // Frequent '#EXT-X-DISCONTINUITY' directives signal switches between the live broadcast and local ad pods.
- const discontinuities = (m3u8Content.match(/#EXT-X-DISCONTINUITY/g) || []).length;
- if (discountinutiesCountCheck(discontinuities)) {
- anomalies.push(`Excessive discontinuity switches found (${discontinuities} in single manifest window). High likelihood of ad pod splice.`);
- isAd = true;
- confidenceSum += 0.40;
- }
-
- // Helper to evaluate count
- function discountinutiesCountCheck(count: number): boolean {
- return count >= 2;
- }
-
- // 3. Timing Anomaly: Segment Duration Variance
- // Standard stream chunks possess highly uniform durations (e.g. exactly 4.000s or 6.000s).
- // SSAI ad segments are frequently spliced at odd duration intervals to pad exact spot limits (e.g. 1.000s, 2.333s).
- const extinfLines = m3u8Content.match(/#EXTINF:([0-9.]+)/g) || [];
- if (extinfLines.length > 0) {
- let abnormalDurations = 0;
- let nonUniformVariance = false;
- const durations: number[] = [];
-
- for (const line of extinfLines) {
- const match = line.match(/#EXTINF:([0-9.]+)/);
- if (match && match[1]) {
- const duration = parseFloat(match[1]);
- durations.push(duration);
-
- // Check for extremely short chunks or chunks deviating from expected container standard
- if (duration < 2.0 && duration !== targetDuration) {
- abnormalDurations++;
- }
- }
- }
-
- // Check for custom math distribution (non-uniform durations)
- if (durations.length > 1) {
- const uniqueDurations = new Set(durations);
- if (uniqueDurations.size > 2) {
- nonUniformVariance = true;
- }
- }
-
- if (abnormalDurations >= 2) {
- anomalies.push(`Timing Drift: Spliced segments detected with abnormal durations (< 2.0s). Count: ${abnormalDurations}`);
- isAd = true;
- confidenceSum += 0.35;
- }
-
- if (nonUniformVariance) {
- anomalies.push("Segment Duration Variance: Inconsistent chunk sizes found within the media playlist sequence.");
- isAd = true;
- confidenceSum += 0.25;
- }
- }
-
- return {
- isAd: isAd || confidenceSum >= 0.5,
- confidence: Math.min(0.99, confidenceSum),
- anomalies
- };
- }
-
- /**
- * Evaluates client-side state indicators like layout modifications, resolution downgrades,
- * and DOM status codes for ad signals.
- */
- public static detectObservableAnomalies(state: {
- resolution: string;
- isMuted: boolean;
- playbackRate: number;
- domSelectorsPresent: string[];
- }): {
- isAd: boolean;
- confidence: number;
- actionRecommended: string;
- } {
- let adDetected = false;
- let confidence = 0;
- let action = "monitor";
-
- // 1. Direct overlay indicators
- if (state.domSelectorsPresent.length > 0) {
- adDetected = true;
- confidence = 0.99;
- action = "mute_and_scale_down";
- return { isAd: adDetected, confidence, actionRecommended: action };
- }
-
- // 2. Structural/Anomalous Downgrade Checks
- // If the stream was set to 1080p, but suddenly shifts to 360p or 480p and is accompanied by unprompted mute state,
- // this mimics the ad-block defensive system feedback.
- if ((state.resolution === "360p" || state.resolution === "480p") && state.isMuted) {
- adDetected = true;
- confidence = 0.75;
- action = "verify_hls_manifest";
- }
-
- return {
- isAd: adDetected,
- confidence,
- actionRecommended: action
- };
- }
-}
diff --git a/TwitchShield/packages/test-fixtures/test-runner.ts b/TwitchShield/packages/test-fixtures/test-runner.ts
deleted file mode 100644
index 425cf46..0000000
--- a/TwitchShield/packages/test-fixtures/test-runner.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { ProtectionMode, ExtensionSettings } from "../core/src/types";
-
-export interface MockTwitchPlayerState {
- isPlaying: boolean;
- isAdPlaying: boolean;
- volume: number; // 0 to 1
- isMuted: boolean;
- quality: string; // "1080p", "720p", "360p"
- playbackStalled: boolean;
-}
-
-export class MockTwitchStream {
- public state: MockTwitchPlayerState;
- private settings: ExtensionSettings;
- private logs: string[] = [];
-
- constructor(settings: ExtensionSettings) {
- this.settings = settings;
- this.state = {
- isPlaying: true,
- isAdPlaying: false,
- volume: 0.8,
- isMuted: false,
- quality: "1080p",
- playbackStalled: false
- };
- }
-
- public enableAd(adDurationMs: number): Promise {
- this.state.isAdPlaying = true;
- this.logs.push(`Ad triggered at ${new Date().toISOString()}`);
-
- // If protective action is enabled
- if (this.settings.enabled) {
- if (this.settings.muteDuringAds) {
- this.state.isMuted = true;
- this.logs.push("TwitchShield Action: Stream muted in response to ad");
- }
- if (this.settings.lowQualityDuringAds) {
- this.state.quality = "360p";
- this.logs.push("TwitchShield Action: Stream quality lowered to bypass network congestion");
- }
- }
-
- return new Promise((resolve) => {
- setTimeout(() => {
- this.disableAd();
- resolve();
- }, adDurationMs);
- });
- }
-
- public disableAd() {
- this.state.isAdPlaying = false;
- this.state.isMuted = false;
- this.state.quality = "1080p";
- this.logs.push(`Ad ended. Player state restored.`);
- }
-
- public triggerStall() {
- this.state.playbackStalled = true;
- this.logs.push("Player error 3000 / WebGL black screen");
-
- if (this.settings.enabled && this.settings.autoRecoveryEnabled) {
- this.state.playbackStalled = false; // recovery
- this.logs.push("TwitchShield Action: Stream reboot triggered automatically to clear error state");
- }
- }
-
- public getLogs(): string[] {
- return this.logs;
- }
-}
diff --git a/TwitchShield/tests/browser-smoke.test.ts b/TwitchShield/tests/browser-smoke.test.ts
index 3ffc61c..2751aff 100644
--- a/TwitchShield/tests/browser-smoke.test.ts
+++ b/TwitchShield/tests/browser-smoke.test.ts
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { spawnSync } from "node:child_process";
-test("Chromium loads the unpacked extension and opens popup/options", async () => {
+test("Chromium loads the extension and records only opt-in controlled player events", async () => {
const build = spawnSync(process.execPath, ["scripts/build-extensions.mjs", "chromium"], {
cwd: resolve("."),
encoding: "utf8"
@@ -33,6 +33,63 @@ test("Chromium loads the unpacked extension and opens popup/options", async () =
await options.goto(`chrome-extension://${extensionId}/options.html`);
await expect(options).toHaveTitle(/TwitchShield/i);
await expect(options.locator("body")).toContainText("TwitchShield");
+
+ const initial = await options.evaluate(async () => await chrome.storage.local.get(["settings", "diagnostics"]) as {
+ settings?: { diagnosticMode?: boolean };
+ diagnostics?: Array<{ eventType: string }>;
+ });
+ expect(initial.settings?.diagnosticMode ?? false).toBe(false);
+ expect(initial.diagnostics ?? []).toEqual([]);
+
+ await options.locator("#btn-diagnostics-toggle").click();
+ await expect(options.locator("#btn-diagnostics-toggle")).toHaveText("Enabled");
+
+ await context.route("https://www.twitch.tv/**", async (route) => {
+ await route.fulfill({
+ contentType: "text/html",
+ body: "Controlled Player Fixture "
+ });
+ });
+ const fixture = await context.newPage();
+ await fixture.goto("https://www.twitch.tv/controlled_channel");
+ await fixture.locator("#player").evaluate((player) => {
+ const video = document.createElement("video");
+ player.appendChild(video);
+ });
+
+ await expect.poll(async () => {
+ const result = await options.evaluate(async () => await chrome.storage.local.get("diagnostics") as {
+ diagnostics?: Array<{ eventType: string }>;
+ });
+ return result.diagnostics?.map((event: { eventType: string }) => event.eventType) ?? [];
+ }).toContain("player_found");
+ await fixture.locator("video").evaluate((video) => video.dispatchEvent(new Event("playing")));
+ await expect.poll(async () => {
+ const result = await options.evaluate(async () => await chrome.storage.local.get("diagnostics") as {
+ diagnostics?: Array<{ eventType: string }>;
+ });
+ return result.diagnostics?.map((event: { eventType: string }) => event.eventType) ?? [];
+ }).toContain("video_playing");
+ const enabledLogs = await options.evaluate(async () => await chrome.storage.local.get("diagnostics") as {
+ diagnostics: Array<{ eventType: string }>;
+ });
+ expect(enabledLogs.diagnostics.some((event: { eventType: string }) => event.eventType === "video_playing")).toBe(true);
+
+ await options.locator("#btn-diagnostics-toggle").click();
+ await expect(options.locator("#btn-diagnostics-toggle")).toHaveText("Disabled");
+ // Let the disabled setting propagate to the content script and any pending
+ // UI-change timers flush, then take the baseline count fresh from storage.
+ await fixture.waitForTimeout(800);
+ const getDiagnosticsCount = async (): Promise => {
+ const result = await options.evaluate(async () => await chrome.storage.local.get("diagnostics") as {
+ diagnostics?: Array<{ eventType: string }>;
+ });
+ return result.diagnostics?.length ?? 0;
+ };
+ const countBefore = await getDiagnosticsCount();
+ await fixture.locator("video").evaluate((video) => video.dispatchEvent(new Event("pause")));
+ await fixture.waitForTimeout(400);
+ expect(await getDiagnosticsCount()).toBe(countBefore);
} finally {
await context.close();
await rm(profile, { force: true, recursive: true });
diff --git a/TwitchShield/tests/build-integrity.test.mjs b/TwitchShield/tests/build-integrity.test.mjs
index 144383e..9bee645 100644
--- a/TwitchShield/tests/build-integrity.test.mjs
+++ b/TwitchShield/tests/build-integrity.test.mjs
@@ -16,6 +16,9 @@ function collectManifestFiles(manifest) {
for (const icon of Object.values(manifest.action?.default_icon ?? {})) files.add(icon);
if (manifest.options_ui?.page) files.add(manifest.options_ui.page);
for (const icon of Object.values(manifest.icons ?? {})) files.add(icon);
+ for (const entry of manifest.web_accessible_resources ?? []) {
+ for (const resource of entry.resources ?? []) files.add(resource);
+ }
return files;
}
diff --git a/TwitchShield/tests/diagnostics.test.ts b/TwitchShield/tests/diagnostics.test.ts
new file mode 100644
index 0000000..a14bab7
--- /dev/null
+++ b/TwitchShield/tests/diagnostics.test.ts
@@ -0,0 +1,71 @@
+import assert from "node:assert/strict";
+import test, { beforeEach } from "node:test";
+
+import { isObservationDisabled } from "../packages/core/src/observation.ts";
+import { TwitchShieldStorage } from "../packages/core/src/storage.ts";
+import { DEFAULT_SETTINGS, DiagnosticEventInput } from "../packages/core/src/types.ts";
+
+class MemoryStorage {
+ private values = new Map();
+ getItem(key: string): string | null { return this.values.get(key) ?? null; }
+ setItem(key: string, value: string): void { this.values.set(key, value); }
+ removeItem(key: string): void { this.values.delete(key); }
+ clear(): void { this.values.clear(); }
+}
+
+const local = new MemoryStorage();
+Object.defineProperty(globalThis, "localStorage", { value: local });
+
+const safeEvent: DiagnosticEventInput = {
+ eventType: "page_loaded",
+ browserTarget: "chromium",
+ pageCategory: "live",
+ channel: "test_channel",
+ status: "Controlled page loaded."
+};
+
+beforeEach(() => local.clear());
+
+test("diagnostics are disabled by default", async () => {
+ assert.equal(DEFAULT_SETTINGS.diagnosticMode, false);
+ assert.equal(await TwitchShieldStorage.appendDiagnostic(safeEvent), null);
+ assert.deepEqual(await TwitchShieldStorage.getDiagnostics(), []);
+});
+
+test("diagnostics persist only approved fields", async () => {
+ await TwitchShieldStorage.saveSettings({ ...DEFAULT_SETTINGS, diagnosticMode: true });
+ await TwitchShieldStorage.appendDiagnostic({
+ ...safeEvent,
+ status: "Safe status https://example.test/private?token=secret",
+ cookie: "forbidden",
+ fullUrl: "https://example.test/private?token=secret",
+ headers: { authorization: "forbidden" }
+ } as DiagnosticEventInput & Record);
+ const [event] = await TwitchShieldStorage.getDiagnostics();
+ assert.deepEqual(Object.keys(event).sort(), ["browserTarget", "channel", "eventType", "pageCategory", "status", "timestamp"].sort());
+ assert.equal(JSON.stringify(event).includes("secret"), false);
+ assert.equal(JSON.stringify(event).includes("forbidden"), false);
+});
+
+test("diagnostics clear without resetting settings", async () => {
+ await TwitchShieldStorage.saveSettings({ ...DEFAULT_SETTINGS, diagnosticMode: true });
+ await TwitchShieldStorage.appendDiagnostic(safeEvent);
+ await TwitchShieldStorage.clearDiagnostics();
+ assert.deepEqual(await TwitchShieldStorage.getDiagnostics(), []);
+ assert.equal((await TwitchShieldStorage.getSettings()).diagnosticMode, true);
+});
+
+test("whitelisted channels disable observation", () => {
+ const settings = { ...DEFAULT_SETTINGS, enabled: true, whitelist: ["test_channel"] };
+ assert.equal(isObservationDisabled(settings, "test_channel"), true);
+ assert.equal(isObservationDisabled(settings, "other_channel"), false);
+});
+
+test("exported diagnostics contain no private fields", async () => {
+ await TwitchShieldStorage.saveSettings({ ...DEFAULT_SETTINGS, diagnosticMode: true });
+ await TwitchShieldStorage.appendDiagnostic(safeEvent);
+ const exported = JSON.stringify(await TwitchShieldStorage.getDiagnostics());
+ for (const forbidden of ["cookie", "token", "authorization", "fullUrl", "chat", "html", "payload"]) {
+ assert.equal(exported.toLowerCase().includes(forbidden.toLowerCase()), false);
+ }
+});
diff --git a/TwitchShield/tests/live-adblock.test.ts b/TwitchShield/tests/live-adblock.test.ts
new file mode 100644
index 0000000..851c8f6
--- /dev/null
+++ b/TwitchShield/tests/live-adblock.test.ts
@@ -0,0 +1,73 @@
+import { chromium, expect, test } from "@playwright/test";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { resolve } from "node:path";
+import { spawnSync } from "node:child_process";
+
+/**
+ * Live ad-block verification.
+ *
+ * This test loads a real Twitch channel and confirms that (a) the ad-block engine
+ * is injected into the page and (b) media playlists observed by the player do not
+ * carry stitched-ad markers. It depends on the network and on a live channel being
+ * online, so it is opt-in: run it with RUN_LIVE=1 and optionally TWITCH_CHANNEL=.
+ *
+ * RUN_LIVE=1 TWITCH_CHANNEL=somechannel npm run test:live:adblock
+ *
+ * It is intentionally excluded from CI, which cannot guarantee a deterministic ad.
+ */
+
+const RUN_LIVE = process.env.RUN_LIVE === "1";
+const CHANNEL = process.env.TWITCH_CHANNEL ?? "twitch";
+
+test.describe("live ad blocking", () => {
+ test.skip(!RUN_LIVE, "Set RUN_LIVE=1 to run live Twitch verification.");
+
+ test(`injects the engine and serves ad-free playlists on ${CHANNEL}`, async () => {
+ const build = spawnSync(process.execPath, ["scripts/build-extensions.mjs", "chromium"], {
+ cwd: resolve("."),
+ encoding: "utf8"
+ });
+ expect(build.status, build.stderr).toBe(0);
+
+ const extensionPath = resolve("build/chromium");
+ const profile = await mkdtemp(resolve(tmpdir(), "twitchshield-live-"));
+ const context = await chromium.launchPersistentContext(profile, {
+ channel: "chromium",
+ headless: false,
+ args: [`--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`]
+ });
+
+ const adMarkerHits: string[] = [];
+ context.on("response", (response) => {
+ const url = response.url();
+ if (!url.includes(".m3u8")) return;
+ void response
+ .text()
+ .then((body) => {
+ if (/stitched-ad|twitch-stitched-ad|#EXT-X-DATERANGE.*twitch-stitched/i.test(body)) {
+ adMarkerHits.push(url);
+ }
+ })
+ .catch(() => undefined);
+ });
+
+ try {
+ const page = await context.newPage();
+ await page.goto(`https://www.twitch.tv/${CHANNEL}`, { waitUntil: "domcontentloaded" });
+
+ // The MAIN-world engine flags itself on the page global once installed.
+ await expect
+ .poll(async () => page.evaluate(() => "__twitchShieldActive__" in self), { timeout: 20_000 })
+ .toBe(true);
+
+ // Give the player time to fetch several playlist windows.
+ await page.waitForTimeout(30_000);
+
+ expect(adMarkerHits, `Ad markers appeared in ${adMarkerHits.length} playlist(s)`).toEqual([]);
+ } finally {
+ await context.close();
+ await rm(profile, { force: true, recursive: true });
+ }
+ });
+});
diff --git a/TwitchShield/tests/simulation.test.ts b/TwitchShield/tests/simulation.test.ts
deleted file mode 100644
index 1b1cdd4..0000000
--- a/TwitchShield/tests/simulation.test.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/** Unit simulations only. These tests do not launch a browser or contact Twitch. */
-import assert from "node:assert/strict";
-import test from "node:test";
-
-import { DEFAULT_SETTINGS, ProtectionMode } from "../packages/core/src/types.ts";
-import { MockTwitchStream } from "../packages/test-fixtures/test-runner.ts";
-
-test("simulation: default settings", () => {
- assert.equal(DEFAULT_SETTINGS.enabled, true);
- assert.equal(DEFAULT_SETTINGS.mode, ProtectionMode.BALANCED);
- assert.equal(DEFAULT_SETTINGS.muteDuringAds, true);
-});
-
-test("simulation: ad state mutes and restores playback", async () => {
- const stream = new MockTwitchStream({ ...DEFAULT_SETTINGS, muteDuringAds: true });
- const ad = stream.enableAd(10);
- assert.equal(stream.state.isMuted, true);
- await ad;
- assert.equal(stream.state.isMuted, false);
-});
-
-test("simulation: ad state lowers and restores quality", async () => {
- const stream = new MockTwitchStream({ ...DEFAULT_SETTINGS, lowQualityDuringAds: true });
- const ad = stream.enableAd(10);
- assert.equal(stream.state.quality, "360p");
- await ad;
- assert.equal(stream.state.quality, "1080p");
-});
-
-test("simulation: configured recovery clears a stall", () => {
- const stream = new MockTwitchStream(DEFAULT_SETTINGS);
- stream.state.isAdPlaying = true;
- stream.triggerStall();
- assert.equal(stream.state.playbackStalled, false);
-});
diff --git a/docs/SUBMISSION.md b/docs/SUBMISSION.md
new file mode 100644
index 0000000..d8f9a27
--- /dev/null
+++ b/docs/SUBMISSION.md
@@ -0,0 +1,110 @@
+# Submitting TwitchShield to the browser stores
+
+This guide covers publishing to the **Chrome Web Store** and **Firefox Add-ons (AMO)**.
+Both accept the same source; only the packaging and review flow differ.
+
+---
+
+## 0. Build the packages
+
+```bash
+npm install
+npm run extension:build # sanity-check both targets build
+npm run pack:chromium # -> artifacts/twitchshield-chromium.zip
+npm run pack:firefox # -> artifacts/twitchshield-firefox.zip
+```
+
+Each archive contains `manifest.json` at its root, which is what both stores expect.
+
+Before uploading, verify the Firefox build passes Mozilla's linter:
+
+```bash
+npm run test:firefox:lint
+```
+
+---
+
+## 1. Chrome Web Store
+
+**One-time setup**
+
+1. Create a developer account at (one-time $5 USD fee).
+2. Have ready: 128×128 icon (included), at least one 1280×800 or 640×400 screenshot, and a short + detailed description.
+
+**Publish**
+
+1. Open the Developer Dashboard → **Add new item**.
+2. Upload `artifacts/twitchshield-chromium.zip`.
+3. Fill in the listing:
+ - **Category:** Productivity (or Tools).
+ - **Description:** Explain that it blocks Twitch ads locally with no data collection. Do **not** over-promise ("100% / guaranteed") — reviewers penalize unverifiable claims.
+ - **Privacy practices:** Declare **no data collected**. Justify permissions:
+ - `storage` — save settings and the local diagnostic log.
+ - `activeTab` — read the current tab's URL to identify the channel for whitelisting.
+ - `host_permissions: *://*.twitch.tv/*` — run the ad-block engine on Twitch only.
+4. Submit for review. Review typically takes a few hours to a few days.
+
+**Common rejection reasons to avoid**
+
+- Requesting broad host permissions — TwitchShield already scopes to Twitch only. ✔
+- Remote code execution — the extension bundles all code; nothing is fetched and `eval`'d. ✔
+- Undisclosed data use — declare "no data collected". ✔
+
+---
+
+## 2. Firefox Add-ons (AMO)
+
+**One-time setup**
+
+1. Create an account at .
+2. The Firefox manifest already includes `browser_specific_settings.gecko.id`
+ (`twitchshield@local.extension`) and `data_collection_permissions: { required: ["none"] }`.
+
+**Publish (listed)**
+
+1. Go to **Submit a New Add-on** → *On this site*.
+2. Upload `artifacts/twitchshield-firefox.zip`.
+3. Mozilla runs an automated validation (same as `web-ext lint`). Fix any errors and re-upload.
+4. Because the extension is minified by esbuild, AMO will ask you to **provide source code**:
+ - Upload a zip of the repository (excluding `node_modules/`, `build/`, `artifacts/`).
+ - In the reviewer notes, state the build command: `npm install && npm run build:firefox`,
+ output in `build/firefox/`.
+5. Fill in the listing (description, category *Privacy & Security*, screenshots) and submit.
+
+**Self-distribution (optional)**
+
+To distribute outside AMO, choose *On your own* during submission. Mozilla signs the
+`.xpi` so users can install it, without a full listing review.
+
+---
+
+## 3. Listing copy (starter)
+
+**Short description**
+
+> Blocks stream-level Twitch ads locally in your browser. Private, open source, no telemetry.
+
+**Detailed description**
+
+> TwitchShield removes server-side-inserted (SSAI) ads from Twitch streams by
+> intercepting the video playlist and swapping ad segments for an ad-free stream —
+> entirely on your device. No backend, no analytics, no account access.
+>
+> • Whitelist channels to support your favorite streamers
+> • Optional on-device diagnostics you can inspect and export
+> • Chrome and Firefox, Manifest V3, MIT-licensed
+>
+> Note: Twitch changes ad delivery frequently, so occasional ads may appear until the
+> extension is updated. Not affiliated with Twitch Interactive, Inc.
+
+---
+
+## 4. Release checklist
+
+- [ ] `npm run lint` clean
+- [ ] `npm test` passes
+- [ ] `npm run test:firefox:lint` passes
+- [ ] Version bumped in both `manifest.json` files and `package.json`
+- [ ] `artifacts/*.zip` rebuilt from a clean `npm run extension:build`
+- [ ] Screenshots current
+- [ ] Privacy declarations: **no data collected** on both stores
diff --git a/docs/diagnostic-data-policy.md b/docs/diagnostic-data-policy.md
new file mode 100644
index 0000000..afdefab
--- /dev/null
+++ b/docs/diagnostic-data-policy.md
@@ -0,0 +1,46 @@
+# Diagnostic Data Policy
+
+## Default and control
+
+- Diagnostic mode is disabled by default.
+- Events are stored only after the user enables diagnostic mode.
+- Disabling diagnostic mode stops new writes.
+- Excluded channels are not observed.
+- Clearing diagnostics removes events without resetting settings.
+
+## Allowed event types
+
+- `page_loaded`
+- `player_found`
+- `video_playing`
+- `video_paused`
+- `video_stalled`
+- `playback_error`
+- `ad_ui_signal_detected`
+- `player_ui_changed`
+- `recovery_candidate_detected`
+- `extension_disabled_on_channel`
+- `diagnostic_export_requested`
+
+## Allowed fields
+
+- timestamp
+- event type
+- browser target
+- page category: live, VOD, clip, or unknown
+- channel identifier only when derived from the visible URL
+- short safe status text
+- duration when relevant
+
+## Prohibited data
+
+Diagnostics do not store or transmit cookies, tokens, authorization data, request headers, full URLs or query strings, viewer identity, account data, IP addresses, chat messages, page HTML snapshots, request or response payloads, or network traffic.
+
+## Storage and export
+
+- Storage uses browser-local extension storage only.
+- No analytics, telemetry upload, backend, remote rules, or external diagnostic service exists.
+- The queue is capped at 200 events; older events are removed locally.
+- Export contains only the stored approved event objects.
+- Status text removes URL-shaped content and is limited to 160 characters.
+- Channel identifiers must match the Twitch-compatible lowercase identifier format before storage.
diff --git a/docs/index.html b/docs/index.html
new file mode 100644
index 0000000..666201f
--- /dev/null
+++ b/docs/index.html
@@ -0,0 +1,214 @@
+
+
+
+
+
+ TwitchShield — block Twitch ads, privately
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
What it does
+
+
🛡️ Stream-level blocking Intercepts the HLS playlist inside the player and swaps out ad segments — the technique that works against Twitch's server-side ad insertion.
+
🔒 Truly private No backend, no analytics, no accounts. Everything runs on your device and talks only to Twitch's own APIs.
+
✅ Whitelist favorites Support the streamers you love — whitelist any channel with one click to let their ads play.
+
🧩 Chrome & Firefox One codebase, both browsers, both on Manifest V3. Reproducible builds you can inspect and package yourself.
+
📊 On-device diagnostics Optional, opt-in event log stored only in your browser. Nothing ever leaves your machine.
+
🛠️ One-file to patch Every Twitch-dependent value lives in a single config module, so keeping up with Twitch is a one-commit change.
+
+
+
+
+
+
+
Install in a minute
+
+
Get the build. Download a release, or clone the repo and run npm install then npm run extension:build.
+
Chrome. Open chrome://extensions, enable Developer mode, click Load unpacked , and select build/chromium.
+
Firefox. Open about:debugging#/runtime/this-firefox, click Load Temporary Add-on , and select build/firefox/manifest.json.
+
Watch ad-free. Open any Twitch channel. Click the toolbar icon for blocked-ad stats or to whitelist a channel.
+
+
+
+
+
+
+
How it works
+
+ Layer Responsibility
+ adblock.tsWraps window.Worker so the ad-block payload loads into the player's HLS worker before it starts.
+ adblock-worker.tsRuns inside the worker: detects stitched ad markers in each playlist and substitutes an ad-free one.
+ adblock-config.tsThe single patchable ruleset — client id, ad markers, player types, token hash.
+ content.tsInjects the engine at document_start and applies the whitelist / on-off state.
+
+
+
+
+
+
+
Honest expectations
+
+ No Twitch ad blocker is perfect. Twitch stitches ads into the video stream and actively
+ changes how it works, so any blocker — this one included — will occasionally let an ad
+ through until it is updated. TwitchShield blocks the large majority of ads and is designed
+ to be easy to patch. It does not modify your account or act on your behalf beyond loading
+ an ad-free stream.
+
+
+
+
+
+
+ Built with TypeScript, esbuild, and Playwright · MIT licensed · © wpggLabs
+ Not affiliated with or endorsed by Twitch Interactive, Inc.
+
+
+
+
diff --git a/docs/store-assets.md b/docs/store-assets.md
new file mode 100644
index 0000000..fef71c7
--- /dev/null
+++ b/docs/store-assets.md
@@ -0,0 +1,52 @@
+# Store listing assets
+
+Source graphics live in [`TwitchShield/assets/store/`](../TwitchShield/assets/store/).
+The promo tile is authored as SVG so it stays crisp at any size; export to PNG at the
+required dimensions before uploading.
+
+## Exporting SVG → PNG
+
+Any of these work:
+
+```bash
+# rsvg-convert (librsvg)
+rsvg-convert -w 440 -h 280 TwitchShield/assets/store/promo-tile.svg -o promo-440x280.png
+
+# Inkscape
+inkscape TwitchShield/assets/store/promo-tile.svg -w 440 -h 280 -o promo-440x280.png
+```
+
+Or open the SVG in any browser and screenshot at the target size.
+
+## Chrome Web Store
+
+| Asset | Size | Notes |
+| --- | --- | --- |
+| Store icon | 128×128 PNG | Already generated: `TwitchShield/assets/icons/icon128.png` |
+| Screenshots | 1280×800 or 640×400 PNG | 1–5 images. Show the popup on a Twitch page and the dashboard. Required. |
+| Small promo tile | 440×280 PNG | Export from `promo-tile.svg`. Optional but recommended. |
+| Marquee promo | 1400×560 PNG | Only for featured placement. Optional. |
+
+## Firefox Add-ons (AMO)
+
+| Asset | Size | Notes |
+| --- | --- | --- |
+| Add-on icon | 128×128 PNG | Reuse `icon128.png`. |
+| Screenshots | up to 2400×1800 PNG/JPG | At least one. Same content as Chrome shots is fine. |
+
+## Capturing screenshots
+
+1. `npm run extension:build` and load `build/chromium` unpacked.
+2. Open a Twitch channel, click the toolbar icon, and capture the popup (blocked-ad
+ counter + whitelist button visible).
+3. Open the dashboard (popup → **Dashboard**) and capture the ad-blocking controls and
+ diagnostics panel.
+4. Crop/scale to the sizes above.
+
+## Checklist
+
+- [ ] `icon128.png` present
+- [ ] At least one 1280×800 screenshot (Chrome)
+- [ ] At least one screenshot (Firefox)
+- [ ] `promo-440x280.png` exported (optional)
+- [ ] No "100%/guaranteed" claims in copy
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 0000000..a851f25
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,26 @@
+import tseslint from "typescript-eslint";
+
+export default tseslint.config(
+ {
+ ignores: ["build/**", "artifacts/**", "node_modules/**", "test-results/**", "docs/**"]
+ },
+ ...tseslint.configs.recommended,
+ {
+ languageOptions: {
+ parserOptions: {
+ projectService: true,
+ tsconfigRootDir: import.meta.dirname
+ }
+ },
+ rules: {
+ "@typescript-eslint/no-floating-promises": "error",
+ "@typescript-eslint/no-explicit-any": "off",
+ "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }]
+ }
+ },
+ {
+ // node:test's `test()` returns a promise by design; not a floating-promise bug.
+ files: ["**/tests/**"],
+ rules: { "@typescript-eslint/no-floating-promises": "off" }
+ }
+);
diff --git a/package-lock.json b/package-lock.json
index 6d81ed5..08abb48 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,8 +12,10 @@
"@types/chrome": "^0.1.43",
"archiver": "^7.0.1",
"esbuild": "^0.25.0",
+ "eslint": "^9.39.4",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
+ "typescript-eslint": "^8.62.1",
"web-ext": "^8.9.0"
}
},
@@ -596,25 +598,90 @@
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
},
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
+ "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.5"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-array/node_modules/brace-expansion": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
+ "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@eslint/config-array/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
"node_modules/@eslint/eslintrc": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
- "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz",
+ "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "ajv": "^6.12.4",
+ "ajv": "^6.14.0",
"debug": "^4.3.2",
- "espree": "^9.6.0",
- "globals": "^13.19.0",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
- "js-yaml": "^4.1.0",
- "minimatch": "^3.1.2",
+ "js-yaml": "^4.1.1",
+ "minimatch": "^3.1.5",
"strip-json-comments": "^3.1.1"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
@@ -648,37 +715,6 @@
"concat-map": "0.0.1"
}
},
- "node_modules/@eslint/eslintrc/node_modules/eslint-visitor-keys": {
- "version": "3.4.3",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
- "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/@eslint/eslintrc/node_modules/espree": {
- "version": "9.6.1",
- "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
- "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "acorn": "^8.9.0",
- "acorn-jsx": "^5.3.2",
- "eslint-visitor-keys": "^3.4.1"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
"node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -713,13 +749,40 @@
}
},
"node_modules/@eslint/js": {
- "version": "8.57.1",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
- "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
+ "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
"dev": true,
"license": "MIT",
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@fluent/syntax": {
@@ -743,6 +806,44 @@
"node": ">= 0.10.0"
}
},
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -805,6 +906,20 @@
"dev": true,
"license": "BSD-3-Clause"
},
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -951,6 +1066,13 @@
"@types/har-format": "*"
}
},
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/filesystem": {
"version": "0.0.36",
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
@@ -975,6 +1097,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/minimatch": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
@@ -1002,122 +1131,693 @@
"@types/node": "*"
}
},
- "node_modules/@ungap/structured-clone": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz",
- "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==",
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.62.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz",
+ "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==",
"dev": true,
- "license": "ISC"
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.62.1",
+ "@typescript-eslint/type-utils": "8.62.1",
+ "@typescript-eslint/utils": "8.62.1",
+ "@typescript-eslint/visitor-keys": "8.62.1",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.62.1",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
},
- "node_modules/abort-controller": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
- "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.62.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz",
+ "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "event-target-shim": "^5.0.0"
+ "@typescript-eslint/scope-manager": "8.62.1",
+ "@typescript-eslint/types": "8.62.1",
+ "@typescript-eslint/typescript-estree": "8.62.1",
+ "@typescript-eslint/visitor-keys": "8.62.1",
+ "debug": "^4.4.3"
},
"engines": {
- "node": ">=6.5"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
- "node_modules/acorn": {
- "version": "8.17.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
- "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.62.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz",
+ "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==",
"dev": true,
"license": "MIT",
- "bin": {
- "acorn": "bin/acorn"
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.62.1",
+ "@typescript-eslint/types": "^8.62.1",
+ "debug": "^4.4.3"
},
"engines": {
- "node": ">=0.4.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
}
},
- "node_modules/acorn-jsx": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
- "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.62.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz",
+ "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.62.1",
+ "@typescript-eslint/visitor-keys": "8.62.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.62.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz",
+ "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==",
"dev": true,
"license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
"peerDependencies": {
- "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
- "node_modules/addons-linter": {
- "version": "7.20.0",
- "resolved": "https://registry.npmjs.org/addons-linter/-/addons-linter-7.20.0.tgz",
- "integrity": "sha512-c6drrGsO91oGyqEdEZ3KIBFrSiSlhM5CKxoebBatH1l2vN1ByuRAlp9YUHLCjypLONThPcb+tVTA7X6yKIhpsw==",
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.62.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz",
+ "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==",
"dev": true,
- "license": "MPL-2.0",
+ "license": "MIT",
"dependencies": {
- "@fluent/syntax": "0.19.0",
- "@fregante/relaxed-json": "2.0.0",
- "@mdn/browser-compat-data": "7.1.5",
- "addons-moz-compare": "1.3.0",
- "addons-scanner-utils": "9.13.0",
- "ajv": "8.17.1",
- "chalk": "4.1.2",
- "cheerio": "1.0.0-rc.12",
- "columnify": "1.6.0",
- "common-tags": "1.8.2",
- "deepmerge": "4.3.1",
- "eslint": "8.57.1",
- "eslint-plugin-no-unsanitized": "4.1.4",
- "eslint-visitor-keys": "4.2.1",
- "espree": "10.4.0",
- "esprima": "4.0.1",
- "fast-json-patch": "3.1.1",
- "image-size": "2.0.2",
- "json-merge-patch": "1.0.2",
- "pino": "9.9.5",
- "semver": "7.7.2",
- "source-map-support": "0.5.21",
- "upath": "2.0.1",
- "yargs": "17.7.2",
- "yauzl": "2.10.0"
+ "@typescript-eslint/types": "8.62.1",
+ "@typescript-eslint/typescript-estree": "8.62.1",
+ "@typescript-eslint/utils": "8.62.1",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
},
- "bin": {
- "addons-linter": "bin/addons-linter"
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.62.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz",
+ "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==",
+ "dev": true,
+ "license": "MIT",
"engines": {
- "node": ">=18.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
}
},
- "node_modules/addons-linter/node_modules/addons-scanner-utils": {
- "version": "9.13.0",
- "resolved": "https://registry.npmjs.org/addons-scanner-utils/-/addons-scanner-utils-9.13.0.tgz",
- "integrity": "sha512-8OnHK/pbvgbCejGlnEYw+V3URSTVHLkMZmV270QtNh8N9pAgK10IaiJ9DcL0FsrufZ9HxRcR8/wkavh1FgK6Kg==",
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.62.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz",
+ "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.62.1",
+ "@typescript-eslint/tsconfig-utils": "8.62.1",
+ "@typescript-eslint/types": "8.62.1",
+ "@typescript-eslint/visitor-keys": "8.62.1",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "5.0.7",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
+ "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.62.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz",
+ "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.62.1",
+ "@typescript-eslint/types": "8.62.1",
+ "@typescript-eslint/typescript-estree": "8.62.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.62.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz",
+ "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.62.1",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz",
+ "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/abort-controller": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "event-target-shim": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=6.5"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.17.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
+ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/addons-linter": {
+ "version": "7.20.0",
+ "resolved": "https://registry.npmjs.org/addons-linter/-/addons-linter-7.20.0.tgz",
+ "integrity": "sha512-c6drrGsO91oGyqEdEZ3KIBFrSiSlhM5CKxoebBatH1l2vN1ByuRAlp9YUHLCjypLONThPcb+tVTA7X6yKIhpsw==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "@fluent/syntax": "0.19.0",
+ "@fregante/relaxed-json": "2.0.0",
+ "@mdn/browser-compat-data": "7.1.5",
+ "addons-moz-compare": "1.3.0",
+ "addons-scanner-utils": "9.13.0",
+ "ajv": "8.17.1",
+ "chalk": "4.1.2",
+ "cheerio": "1.0.0-rc.12",
+ "columnify": "1.6.0",
+ "common-tags": "1.8.2",
+ "deepmerge": "4.3.1",
+ "eslint": "8.57.1",
+ "eslint-plugin-no-unsanitized": "4.1.4",
+ "eslint-visitor-keys": "4.2.1",
+ "espree": "10.4.0",
+ "esprima": "4.0.1",
+ "fast-json-patch": "3.1.1",
+ "image-size": "2.0.2",
+ "json-merge-patch": "1.0.2",
+ "pino": "9.9.5",
+ "semver": "7.7.2",
+ "source-map-support": "0.5.21",
+ "upath": "2.0.1",
+ "yargs": "17.7.2",
+ "yauzl": "2.10.0"
+ },
+ "bin": {
+ "addons-linter": "bin/addons-linter"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/addons-linter/node_modules/@eslint/eslintrc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+ "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^9.6.0",
+ "globals": "^13.19.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/addons-linter/node_modules/@eslint/eslintrc/node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/addons-linter/node_modules/@eslint/eslintrc/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/addons-linter/node_modules/@eslint/eslintrc/node_modules/espree": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
+ "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.9.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^3.4.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/addons-linter/node_modules/@eslint/js": {
+ "version": "8.57.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
+ "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/addons-linter/node_modules/addons-scanner-utils": {
+ "version": "9.13.0",
+ "resolved": "https://registry.npmjs.org/addons-scanner-utils/-/addons-scanner-utils-9.13.0.tgz",
+ "integrity": "sha512-8OnHK/pbvgbCejGlnEYw+V3URSTVHLkMZmV270QtNh8N9pAgK10IaiJ9DcL0FsrufZ9HxRcR8/wkavh1FgK6Kg==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "@types/yauzl": "2.10.3",
+ "common-tags": "1.8.2",
+ "first-chunk-stream": "3.0.0",
+ "strip-bom-stream": "4.0.0",
+ "upath": "2.0.1",
+ "yauzl": "2.10.0"
+ },
+ "peerDependencies": {
+ "body-parser": "1.20.3",
+ "express": "4.21.2",
+ "node-fetch": "2.6.11",
+ "safe-compare": "1.1.4"
+ },
+ "peerDependenciesMeta": {
+ "body-parser": {
+ "optional": true
+ },
+ "express": {
+ "optional": true
+ },
+ "node-fetch": {
+ "optional": true
+ },
+ "safe-compare": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/addons-linter/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/addons-linter/node_modules/brace-expansion": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
+ "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/addons-linter/node_modules/eslint": {
+ "version": "8.57.1",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
+ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
+ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/regexpp": "^4.6.1",
+ "@eslint/eslintrc": "^2.1.4",
+ "@eslint/js": "8.57.1",
+ "@humanwhocodes/config-array": "^0.13.0",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@nodelib/fs.walk": "^1.2.8",
+ "@ungap/structured-clone": "^1.2.0",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.2",
+ "debug": "^4.3.2",
+ "doctrine": "^3.0.0",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^7.2.2",
+ "eslint-visitor-keys": "^3.4.3",
+ "espree": "^9.6.1",
+ "esquery": "^1.4.2",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^6.0.1",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "globals": "^13.19.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "is-path-inside": "^3.0.3",
+ "js-yaml": "^4.1.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "levn": "^0.4.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3",
+ "strip-ansi": "^6.0.1",
+ "text-table": "^0.2.0"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/addons-linter/node_modules/eslint-scope": {
+ "version": "7.2.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
+ "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/addons-linter/node_modules/eslint/node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/addons-linter/node_modules/eslint/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/addons-linter/node_modules/eslint/node_modules/espree": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
+ "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.9.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^3.4.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/addons-linter/node_modules/file-entry-cache": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+ "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^3.0.4"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/addons-linter/node_modules/flat-cache": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
+ "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.3",
+ "rimraf": "^3.0.2"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/addons-linter/node_modules/globals": {
+ "version": "13.24.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
+ "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^0.20.2"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/addons-linter/node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/addons-linter/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
- "license": "MPL-2.0",
+ "license": "ISC",
"dependencies": {
- "@types/yauzl": "2.10.3",
- "common-tags": "1.8.2",
- "first-chunk-stream": "3.0.0",
- "strip-bom-stream": "4.0.0",
- "upath": "2.0.1",
- "yauzl": "2.10.0"
- },
- "peerDependencies": {
- "body-parser": "1.20.3",
- "express": "4.21.2",
- "node-fetch": "2.6.11",
- "safe-compare": "1.1.4"
+ "brace-expansion": "^1.1.7"
},
- "peerDependenciesMeta": {
- "body-parser": {
- "optional": true
- },
- "express": {
- "optional": true
- },
- "node-fetch": {
- "optional": true
- },
- "safe-compare": {
- "optional": true
- }
+ "engines": {
+ "node": "*"
}
},
"node_modules/addons-linter/node_modules/semver": {
@@ -1133,6 +1833,32 @@
"node": ">=10"
}
},
+ "node_modules/addons-linter/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/addons-linter/node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/addons-moz-compare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/addons-moz-compare/-/addons-moz-compare-1.3.0.tgz",
@@ -2499,60 +3225,63 @@
}
},
"node_modules/eslint": {
- "version": "8.57.1",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
- "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
- "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
+ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/eslint-utils": "^4.2.0",
- "@eslint-community/regexpp": "^4.6.1",
- "@eslint/eslintrc": "^2.1.4",
- "@eslint/js": "8.57.1",
- "@humanwhocodes/config-array": "^0.13.0",
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.2",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.5",
+ "@eslint/js": "9.39.4",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
- "@nodelib/fs.walk": "^1.2.8",
- "@ungap/structured-clone": "^1.2.0",
- "ajv": "^6.12.4",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
"chalk": "^4.0.0",
- "cross-spawn": "^7.0.2",
+ "cross-spawn": "^7.0.6",
"debug": "^4.3.2",
- "doctrine": "^3.0.0",
"escape-string-regexp": "^4.0.0",
- "eslint-scope": "^7.2.2",
- "eslint-visitor-keys": "^3.4.3",
- "espree": "^9.6.1",
- "esquery": "^1.4.2",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
- "file-entry-cache": "^6.0.1",
+ "file-entry-cache": "^8.0.0",
"find-up": "^5.0.0",
"glob-parent": "^6.0.2",
- "globals": "^13.19.0",
- "graphemer": "^1.4.0",
"ignore": "^5.2.0",
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
- "is-path-inside": "^3.0.3",
- "js-yaml": "^4.1.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
- "levn": "^0.4.1",
"lodash.merge": "^4.6.2",
- "minimatch": "^3.1.2",
+ "minimatch": "^3.1.5",
"natural-compare": "^1.4.0",
- "optionator": "^0.9.3",
- "strip-ansi": "^6.0.1",
- "text-table": "^0.2.0"
+ "optionator": "^0.9.3"
},
"bin": {
"eslint": "bin/eslint.js"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
- "url": "https://opencollective.com/eslint"
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
}
},
"node_modules/eslint-plugin-no-unsanitized": {
@@ -2566,9 +3295,9 @@
}
},
"node_modules/eslint-scope": {
- "version": "7.2.2",
- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
- "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@@ -2576,7 +3305,7 @@
"estraverse": "^5.2.0"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
@@ -2612,16 +3341,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
- "node_modules/eslint/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
@@ -2633,37 +3352,6 @@
"concat-map": "0.0.1"
}
},
- "node_modules/eslint/node_modules/eslint-visitor-keys": {
- "version": "3.4.3",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
- "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/eslint/node_modules/espree": {
- "version": "9.6.1",
- "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
- "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "acorn": "^8.9.0",
- "acorn-jsx": "^5.3.2",
- "eslint-visitor-keys": "^3.4.1"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
"node_modules/eslint/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -2684,19 +3372,6 @@
"node": "*"
}
},
- "node_modules/eslint/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
@@ -2887,17 +3562,35 @@
"pend": "~1.2.0"
}
},
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
"node_modules/file-entry-cache": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
- "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "flat-cache": "^3.0.4"
+ "flat-cache": "^4.0.0"
},
"engines": {
- "node": "^10.12.0 || >=12.0.0"
+ "node": ">=16.0.0"
}
},
"node_modules/find-up": {
@@ -2948,18 +3641,17 @@
}
},
"node_modules/flat-cache": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
- "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"flatted": "^3.2.9",
- "keyv": "^4.5.3",
- "rimraf": "^3.0.2"
+ "keyv": "^4.5.4"
},
"engines": {
- "node": "^10.12.0 || >=12.0.0"
+ "node": ">=16"
}
},
"node_modules/flatted": {
@@ -3167,16 +3859,13 @@
}
},
"node_modules/globals": {
- "version": "13.24.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
- "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "type-fest": "^0.20.2"
- },
"engines": {
- "node": ">=8"
+ "node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@@ -3624,9 +4313,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
- "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
+ "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
@@ -4038,19 +4727,6 @@
"which": "^2.0.2"
}
},
- "node_modules/node-notifier/node_modules/semver": {
- "version": "7.8.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
- "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -4198,19 +4874,6 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
- "node_modules/package-json/node_modules/semver": {
- "version": "7.8.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
- "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
@@ -4372,6 +5035,19 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/pino": {
"version": "9.9.5",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.9.5.tgz",
@@ -4871,6 +5547,19 @@
"node": ">=11.0.0"
}
},
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
@@ -5260,6 +5949,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
@@ -5270,6 +5976,19 @@
"node": ">=14.14"
}
},
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
"node_modules/tsx": {
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
@@ -5820,6 +6539,30 @@
"node": ">=14.17"
}
},
+ "node_modules/typescript-eslint": {
+ "version": "8.62.1",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz",
+ "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.62.1",
+ "@typescript-eslint/parser": "8.62.1",
+ "@typescript-eslint/typescript-estree": "8.62.1",
+ "@typescript-eslint/utils": "8.62.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -5886,19 +6629,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
- "node_modules/update-notifier/node_modules/semver": {
- "version": "7.8.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
- "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
diff --git a/package.json b/package.json
index 8897b89..015a180 100644
--- a/package.json
+++ b/package.json
@@ -3,19 +3,26 @@
"private": true,
"version": "1.0.0",
"type": "module",
+ "engines": {
+ "node": ">=20"
+ },
"scripts": {
"clean": "node scripts/clean.mjs",
- "lint": "tsc --noEmit",
- "typecheck": "npm run lint",
+ "lint": "npm run lint:types && npm run lint:style",
+ "lint:types": "tsc --noEmit",
+ "lint:style": "eslint \"TwitchShield/**/*.ts\"",
+ "typecheck": "npm run lint:types",
"build:chromium": "node scripts/build-extensions.mjs chromium",
"build:firefox": "node scripts/build-extensions.mjs firefox",
"extension:build": "node scripts/build-extensions.mjs all",
"pack:chromium": "node scripts/package-extension.mjs chromium",
"pack:firefox": "node scripts/package-extension.mjs firefox",
"test": "npm run test:unit && npm run test:integrity",
- "test:unit": "tsx --test TwitchShield/tests/simulation.test.ts",
+ "test:unit": "tsx --test TwitchShield/tests/diagnostics.test.ts",
"test:integrity": "node --test TwitchShield/tests/build-integrity.test.mjs",
"test:smoke:chromium": "playwright test TwitchShield/tests/browser-smoke.test.ts",
+ "test:live:adblock": "playwright test TwitchShield/tests/live-adblock.test.ts",
+ "test:live:chromium": "node scripts/live-observation-chromium.mjs",
"test:firefox:lint": "web-ext lint --source-dir build/firefox"
},
"devDependencies": {
@@ -23,8 +30,10 @@
"@types/chrome": "^0.1.43",
"archiver": "^7.0.1",
"esbuild": "^0.25.0",
+ "eslint": "^9.39.4",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
+ "typescript-eslint": "^8.62.1",
"web-ext": "^8.9.0"
}
}
diff --git a/playwright.config.ts b/playwright.config.ts
index 7426297..0e43df2 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "TwitchShield/tests",
- testMatch: "browser-smoke.test.ts",
+ testMatch: ["browser-smoke.test.ts", "live-adblock.test.ts"],
timeout: 30_000,
workers: 1,
reporter: "line"
diff --git a/scripts/build-extensions.mjs b/scripts/build-extensions.mjs
index b48eb25..bfe71fd 100644
--- a/scripts/build-extensions.mjs
+++ b/scripts/build-extensions.mjs
@@ -13,7 +13,7 @@ export async function buildExtension(target) {
await rm(output, { force: true, recursive: true });
await mkdir(resolve(output, "icons"), { recursive: true });
- for (const name of ["background", "content", "popup", "options"]) {
+ for (const name of ["background", "content", "adblock", "popup", "options"]) {
await build({
entryPoints: [resolve(source, `${name}.ts`)],
outfile: resolve(output, `${name}.js`),
@@ -21,6 +21,7 @@ export async function buildExtension(target) {
format: "iife",
platform: "browser",
target: target === "firefox" ? "firefox100" : "chrome109",
+ define: { __BROWSER_TARGET__: JSON.stringify(target) },
legalComments: "none"
});
}
diff --git a/scripts/live-observation-chromium.mjs b/scripts/live-observation-chromium.mjs
new file mode 100644
index 0000000..c538117
--- /dev/null
+++ b/scripts/live-observation-chromium.mjs
@@ -0,0 +1,109 @@
+import { chromium } from "@playwright/test";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { resolve } from "node:path";
+import { buildExtension } from "./build-extensions.mjs";
+
+await buildExtension("chromium");
+const extensionPath = resolve("build/chromium");
+const profile = await mkdtemp(resolve(tmpdir(), "twitchshield-live-"));
+const context = await chromium.launchPersistentContext(profile, {
+ channel: "chromium",
+ headless: true,
+ args: [`--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`]
+});
+
+const evidence = {
+ browserVersion: await context.browser()?.version(),
+ extensionLoaded: false,
+ diagnosticsDefaultOff: false,
+ popupOpened: false,
+ optionsOpened: false,
+ pages: [],
+ channelSwitchCompleted: false,
+ diagnosticsDisabledStopsWrites: false
+};
+
+async function diagnosticEvents(options) {
+ return await options.evaluate(async () => {
+ const result = await chrome.storage.local.get("diagnostics");
+ return Array.isArray(result.diagnostics) ? result.diagnostics : [];
+ });
+}
+
+async function observePage(page, pageCategory, destination) {
+ const before = await diagnosticEvents(options);
+ let result = "loaded";
+ try {
+ await page.goto(destination, { waitUntil: "domcontentloaded", timeout: 45_000 });
+ await page.waitForTimeout(10_000);
+ } catch {
+ result = "navigation_timeout";
+ }
+ const state = await page.evaluate(() => ({
+ titlePresent: Boolean(document.title),
+ videoPresent: Boolean(document.querySelector("video")),
+ adUiSignalPresent: Boolean(document.querySelector(
+ '[data-a-target="video-ad-label"], [data-a-target="video-ad-countdown"], [data-a-target="player-overlay-advertisement"]'
+ ))
+ })).catch(() => ({ titlePresent: false, videoPresent: false, adUiSignalPresent: false }));
+ const after = await diagnosticEvents(options);
+ evidence.pages.push({
+ pageCategory,
+ result,
+ ...state,
+ eventsRecorded: after.length - before.length,
+ eventTypes: [...new Set(after.slice(0, Math.max(0, after.length - before.length)).map((event) => event.eventType))]
+ });
+}
+
+try {
+ let worker = context.serviceWorkers()[0];
+ if (!worker) worker = await context.waitForEvent("serviceworker", { timeout: 20_000 });
+ const extensionId = new URL(worker.url()).host;
+ evidence.extensionLoaded = true;
+
+ const popup = await context.newPage();
+ await popup.goto(`chrome-extension://${extensionId}/popup.html`);
+ evidence.popupOpened = (await popup.title()).includes("TwitchShield");
+
+ var options = await context.newPage();
+ await options.goto(`chrome-extension://${extensionId}/options.html`);
+ evidence.optionsOpened = (await options.title()).includes("TwitchShield");
+ const initial = await options.evaluate(async () => await chrome.storage.local.get("settings"));
+ evidence.diagnosticsDefaultOff = !(initial.settings?.diagnosticMode ?? false);
+ await options.locator("#btn-diagnostics-toggle").click();
+
+ const live = await context.newPage();
+ await observePage(live, "live", "https://www.twitch.tv/monstercat");
+ await observePage(live, "live-channel-switch", "https://www.twitch.tv/twitch");
+ evidence.channelSwitchCompleted = true;
+
+ const vodIndex = await context.newPage();
+ await vodIndex.goto("https://www.twitch.tv/monstercat/videos?filter=archives", { waitUntil: "domcontentloaded", timeout: 45_000 }).catch(() => undefined);
+ await vodIndex.waitForTimeout(8_000);
+ const vodPath = await vodIndex.locator('a[href*="/videos/"]').evaluateAll((links) => {
+ const href = links.map((link) => link.getAttribute("href")).find((value) => /^\/videos\/\d+$/.test(value || ""));
+ return href || null;
+ });
+ await observePage(vodIndex, "VOD", vodPath ? `https://www.twitch.tv${vodPath}` : "https://www.twitch.tv/videos/2759276204");
+
+ const clips = await context.newPage();
+ await clips.goto("https://www.twitch.tv/monstercat/clips?filter=clips&range=7d", { waitUntil: "domcontentloaded", timeout: 45_000 }).catch(() => undefined);
+ await clips.waitForTimeout(8_000);
+ const clipDestination = await clips.locator('a[href*="clips.twitch.tv"]').evaluateAll((links) => {
+ return links.map((link) => link.getAttribute("href")).find((value) => value?.startsWith("https://clips.twitch.tv/")) || null;
+ });
+ await observePage(clips, "clip", clipDestination || "https://clips.twitch.tv/GiftedRespectfulParrotGOWSkull-17xBRPKKdDvr_3Wf");
+
+ const beforeDisable = (await diagnosticEvents(options)).length;
+ await options.locator("#btn-diagnostics-toggle").click();
+ await live.reload({ waitUntil: "domcontentloaded" });
+ await live.waitForTimeout(2_000);
+ evidence.diagnosticsDisabledStopsWrites = (await diagnosticEvents(options)).length === beforeDisable;
+} finally {
+ await context.close();
+ await rm(profile, { force: true, recursive: true });
+}
+
+console.log(JSON.stringify(evidence, null, 2));