├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── build.yml │ ├── dependency-review.yml │ └── codeql.yml ├── .parcelrc ├── .env.development.example ├── src ├── common │ ├── ts │ │ ├── $.ts │ │ ├── $$.ts │ │ ├── isChromium.ts │ │ ├── getHostFromUrl.ts │ │ ├── toAbsoluteUrl.ts │ │ ├── isChannelWhitelisted.ts │ │ ├── isFlaggedRequest.ts │ │ ├── findChannelFromUsherUrl.ts │ │ ├── acceptFlag.ts │ │ ├── findChannelFromVideoWeaverUrl.ts │ │ ├── findChannelFromTwitchTvUrl.ts │ │ ├── areAllTabsWhitelisted.ts │ │ ├── generateRandomString.ts │ │ ├── Logger.ts │ │ ├── streamStatus.ts │ │ ├── regexes.ts │ │ ├── filterResponseDataWrapper.ts │ │ ├── file.ts │ │ ├── setUserExperienceMode.ts │ │ ├── proxyInfo.ts │ │ ├── isRequestTypeProxied.ts │ │ ├── ipAddress.ts │ │ ├── updateDnsResponses.ts │ │ ├── proxySettings.ts │ │ ├── adLog.ts │ │ └── countryCodes.ts │ ├── images │ │ ├── passport.png │ │ ├── brand │ │ │ ├── icon.png │ │ │ └── favicon.ico │ │ ├── options_bg.png │ │ └── badges │ │ │ ├── firefox_addons.png │ │ │ └── chrome_web_store.png │ ├── fonts │ │ └── Inter-VariableFont_slnt,wght.ttf │ └── css │ │ └── boilerplate.css ├── parcel.d.ts ├── store │ ├── utils.ts │ ├── types.ts │ ├── handlers │ │ ├── getStateHandler.ts │ │ └── getPropertyHandler.ts │ ├── getDefaultState.ts │ └── index.ts ├── background │ ├── handlers │ │ ├── onTabReplaced.ts │ │ ├── onTabRemoved.ts │ │ ├── onBeforeSendHeaders.ts │ │ ├── onInstalled.ts │ │ ├── onStartupStoreCleanup.ts │ │ ├── onTabCreated.ts │ │ ├── checkForOpenedTwitchTabs.ts │ │ ├── onAuthRequired.ts │ │ ├── onTabUpdated.ts │ │ ├── onContentScriptMessage.ts │ │ ├── onProxyRequest.ts │ │ └── onResponseStarted.ts │ └── background.ts ├── rulesets │ └── ruleset.json ├── m3u8-parser.d.ts ├── page │ ├── types.ts │ ├── worker.ts │ ├── sendMessage.ts │ ├── getWorker.ts │ └── page.ts ├── manifest.chromium.json ├── manifest.firefox.json ├── setup │ ├── setup.ts │ ├── page.html │ └── style.css ├── types.ts ├── popup │ ├── style.css │ └── menu.html ├── content │ └── content.ts └── options │ └── style.css ├── .gitattributes ├── .prettierrc ├── tsconfig.json ├── PRIVACY.md ├── CONTRIBUTING.md ├── package.json ├── .gitignore ├── README.md └── CODE_OF_CONDUCT.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [younesaassila, zGato] 2 | -------------------------------------------------------------------------------- /.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-webextension" 3 | } 4 | -------------------------------------------------------------------------------- /.env.development.example: -------------------------------------------------------------------------------- 1 | DEV_OPTIMIZED_PROXIES= 2 | DEV_NORMAL_PROXIES= 3 | BETA= 4 | -------------------------------------------------------------------------------- /src/common/ts/$.ts: -------------------------------------------------------------------------------- 1 | const $ = document.querySelector.bind(document); 2 | 3 | export default $; 4 | -------------------------------------------------------------------------------- /src/common/ts/$$.ts: -------------------------------------------------------------------------------- 1 | const $$ = document.querySelectorAll.bind(document); 2 | 3 | export default $$; 4 | -------------------------------------------------------------------------------- /src/common/images/passport.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/younesaassila/ttv-lol-pro/HEAD/src/common/images/passport.png -------------------------------------------------------------------------------- /src/common/images/brand/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/younesaassila/ttv-lol-pro/HEAD/src/common/images/brand/icon.png -------------------------------------------------------------------------------- /src/common/images/options_bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/younesaassila/ttv-lol-pro/HEAD/src/common/images/options_bg.png -------------------------------------------------------------------------------- /src/common/images/brand/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/younesaassila/ttv-lol-pro/HEAD/src/common/images/brand/favicon.ico -------------------------------------------------------------------------------- /src/common/images/badges/firefox_addons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/younesaassila/ttv-lol-pro/HEAD/src/common/images/badges/firefox_addons.png -------------------------------------------------------------------------------- /src/common/images/badges/chrome_web_store.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/younesaassila/ttv-lol-pro/HEAD/src/common/images/badges/chrome_web_store.png -------------------------------------------------------------------------------- /src/common/ts/isChromium.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | 3 | export default !browser.runtime.getURL("index.html").startsWith("moz"); 4 | -------------------------------------------------------------------------------- /src/common/fonts/Inter-VariableFont_slnt,wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/younesaassila/ttv-lol-pro/HEAD/src/common/fonts/Inter-VariableFont_slnt,wght.ttf -------------------------------------------------------------------------------- /src/common/ts/getHostFromUrl.ts: -------------------------------------------------------------------------------- 1 | export default function getHostFromUrl(url: string) { 2 | try { 3 | return new URL(url).host; 4 | } catch { 5 | return null; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/parcel.d.ts: -------------------------------------------------------------------------------- 1 | // From https://parceljs.org/features/dependency-resolution/#configuring-other-tools 2 | declare module "url:*" { 3 | const value: string; 4 | export default value; 5 | } 6 | -------------------------------------------------------------------------------- /src/common/ts/toAbsoluteUrl.ts: -------------------------------------------------------------------------------- 1 | export default function toAbsoluteUrl(url: string): string { 2 | try { 3 | return new URL(url, location.href).href; 4 | } catch { 5 | return url; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | # Set language for config files with unspecific extensions 4 | .parcelrc linguist-language=JSON 5 | .prettierrc linguist-language=JSON 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "cssDeclarationSorterOrder": "smacss", 4 | "endOfLine": "auto", 5 | "plugins": ["prettier-plugin-css-order", "prettier-plugin-organize-imports"], 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*"], 3 | "compilerOptions": { 4 | "target": "es2023", 5 | "module": "preserve", 6 | "moduleResolution": "bundler", 7 | "noEmit": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "allowSyntheticDefaultImports": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/common/ts/isChannelWhitelisted.ts: -------------------------------------------------------------------------------- 1 | import store from "../../store"; 2 | 3 | export default function isChannelWhitelisted( 4 | channelName: string | null 5 | ): boolean { 6 | if (!channelName) return false; 7 | const channelNameLower = channelName.toLowerCase(); 8 | return store.state.whitelistedChannels.some( 9 | channel => channel.toLowerCase() === channelNameLower 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/common/ts/isFlaggedRequest.ts: -------------------------------------------------------------------------------- 1 | import { WebRequest } from "webextension-polyfill"; 2 | import acceptFlag from "./acceptFlag"; 3 | 4 | export default function isFlaggedRequest( 5 | headers: WebRequest.HttpHeaders | undefined 6 | ): boolean { 7 | if (!headers) return false; 8 | return headers.some( 9 | header => 10 | header.name.toLowerCase() === "accept" && 11 | header.value?.includes(acceptFlag) 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/store/utils.ts: -------------------------------------------------------------------------------- 1 | import { ProxyFlags } from "./types"; 2 | 3 | export function isProxy(value: any) { 4 | return value && value[ProxyFlags.IS_PROXY]; 5 | } 6 | 7 | export function toRaw(value: any) { 8 | if (isProxy(value)) return value[ProxyFlags.RAW]; 9 | if (typeof value === "object" && value !== null) { 10 | for (let key in value) { 11 | value[key] = toRaw(value[key]); 12 | } 13 | } 14 | return value; 15 | } 16 | -------------------------------------------------------------------------------- /src/background/handlers/onTabReplaced.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | import onTabCreated from "./onTabCreated"; 3 | import onTabRemoved from "./onTabRemoved"; 4 | 5 | export default function onTabReplaced( 6 | addedTabId: number, 7 | removedTabId: number 8 | ): void { 9 | onTabRemoved(removedTabId); 10 | browser.tabs 11 | .get(addedTabId) 12 | .then(tab => onTabCreated(tab)) 13 | .catch(() => console.error("❌ Failed to get tab after replacement.")); 14 | } 15 | -------------------------------------------------------------------------------- /src/rulesets/ruleset.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "priority": 1, 5 | "action": { 6 | "type": "block" 7 | }, 8 | "condition": { 9 | "urlFilter": "*.twitch.tv/r/s/*", 10 | "resourceTypes": ["script"] 11 | } 12 | }, 13 | { 14 | "id": 2, 15 | "priority": 1, 16 | "action": { 17 | "type": "block" 18 | }, 19 | "condition": { 20 | "urlFilter": "*.twitch.tv/r/c/*", 21 | "resourceTypes": ["image"] 22 | } 23 | } 24 | ] 25 | -------------------------------------------------------------------------------- /src/common/ts/findChannelFromUsherUrl.ts: -------------------------------------------------------------------------------- 1 | import { twitchApiChannelNameRegex } from "./regexes"; 2 | 3 | /** 4 | * Returns the channel name from a Twitch Usher URL. 5 | * Returns `null` if the URL is not a valid Usher URL. 6 | * @param usherUrl 7 | * @returns 8 | */ 9 | export default function findChannelFromUsherUrl( 10 | usherUrl: string 11 | ): string | null { 12 | const match = twitchApiChannelNameRegex.exec(usherUrl); 13 | if (!match) return null; 14 | const [, channelName] = match; 15 | return channelName; 16 | } 17 | -------------------------------------------------------------------------------- /src/common/ts/acceptFlag.ts: -------------------------------------------------------------------------------- 1 | // PROXYING SPECIFIC REQUESTS WORKS BY SETTING A FLAG IN THE ACCEPT HEADER. 2 | 3 | // This flag is then caught by the `onProxyRequest` listener, which proxies 4 | // the request, then by the `onBeforeSendHeaders` listener, 5 | // which removes the flag. 6 | 7 | // I tried adding a custom header, but even though it got removed by the 8 | // `onBeforeSendHeaders` listener, it still caused the CORS preflight request 9 | // to fail. 10 | 11 | const acceptFlag = "TTV-LOL-PRO"; 12 | 13 | export default acceptFlag; 14 | -------------------------------------------------------------------------------- /src/common/ts/findChannelFromVideoWeaverUrl.ts: -------------------------------------------------------------------------------- 1 | import store from "../../store"; 2 | 3 | /** 4 | * Returns the channel name from a Video Weaver URL. 5 | * Returns `null` if the URL is not a valid Video Weaver URL. 6 | * @param videoWeaverUrl 7 | * @returns 8 | */ 9 | export default function findChannelFromVideoWeaverUrl(videoWeaverUrl: string) { 10 | const channelName = Object.keys(store.state.videoWeaverUrlsByChannel).find( 11 | channelName => 12 | store.state.videoWeaverUrlsByChannel[channelName].includes(videoWeaverUrl) 13 | ); 14 | return channelName ?? null; 15 | } 16 | -------------------------------------------------------------------------------- /src/common/ts/findChannelFromTwitchTvUrl.ts: -------------------------------------------------------------------------------- 1 | import { twitchChannelNameRegex } from "./regexes"; 2 | 3 | /** 4 | * Returns the channel name from a Twitch.tv URL in lowercase. 5 | * Returns `null` if the URL is not a valid Twitch.tv URL. 6 | * @param twitchTvUrl 7 | * @returns 8 | */ 9 | export default function findChannelFromTwitchTvUrl( 10 | twitchTvUrl: string | undefined 11 | ): string | null { 12 | if (!twitchTvUrl) return null; 13 | const match = twitchChannelNameRegex.exec(twitchTvUrl); 14 | if (!match) return null; 15 | const [, channelName] = match; 16 | return channelName.toLowerCase(); 17 | } 18 | -------------------------------------------------------------------------------- /src/common/ts/areAllTabsWhitelisted.ts: -------------------------------------------------------------------------------- 1 | import { Tabs } from "webextension-polyfill"; 2 | import findChannelFromTwitchTvUrl from "./findChannelFromTwitchTvUrl"; 3 | import isChannelWhitelisted from "./isChannelWhitelisted"; 4 | 5 | export default function areAllTabsWhitelisted(tabs: Tabs.Tab[]): boolean { 6 | return tabs.every(tab => { 7 | const url = tab.url || tab.pendingUrl; 8 | if (!url) return false; 9 | const channelName = findChannelFromTwitchTvUrl(url); 10 | const isWhitelisted = channelName 11 | ? isChannelWhitelisted(channelName) 12 | : false; 13 | return isWhitelisted; 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/common/ts/generateRandomString.ts: -------------------------------------------------------------------------------- 1 | export const enum Charset { 2 | ALPHANUMERIC = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", 3 | ALPHANUMERIC_LOWERCASE = "abcdefghijklmnopqrstuvwxyz0123456789", 4 | ALPHANUMERIC_UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 5 | NUMERIC = "0123456789", 6 | } 7 | 8 | export default function generateRandomString( 9 | length: number, 10 | charset: Charset = Charset.ALPHANUMERIC 11 | ): string { 12 | const randomArray = new Uint8Array(length); 13 | crypto.getRandomValues(randomArray); 14 | let result = ""; 15 | randomArray.forEach(number => { 16 | result += charset[number % charset.length]; 17 | }); 18 | return result; 19 | } 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /src/common/ts/Logger.ts: -------------------------------------------------------------------------------- 1 | export default class Logger { 2 | private readonly _prefix: string; 3 | private _debugOnceKeys: Set = new Set(); 4 | 5 | constructor(context?: string) { 6 | this._prefix = context ? `[TTV LOL PRO] (${context})` : "[TTV LOL PRO]"; 7 | } 8 | 9 | log(...data: any[]) { 10 | console.log(this._prefix, ...data); 11 | } 12 | 13 | warn(...data: any[]) { 14 | console.warn(this._prefix, ...data); 15 | } 16 | 17 | error(...data: any[]) { 18 | console.error(this._prefix, ...data); 19 | } 20 | 21 | debug(...data: any[]) { 22 | console.debug(this._prefix, ...data); 23 | } 24 | 25 | debugOnce(key: string, ...data: any[]) { 26 | if (!this._debugOnceKeys.has(key)) { 27 | this._debugOnceKeys.add(key); 28 | this.debug(...data); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/common/ts/streamStatus.ts: -------------------------------------------------------------------------------- 1 | import store from "../../store"; 2 | import type { StreamStatus } from "../../types"; 3 | 4 | /** 5 | * Safely get the stream status for a channel. 6 | * @param channelName 7 | * @returns 8 | */ 9 | export function getStreamStatus( 10 | channelName: string | null 11 | ): StreamStatus | null { 12 | if (!channelName) return null; 13 | return store.state.streamStatuses[channelName] ?? null; 14 | } 15 | 16 | /** 17 | * Safely set the stream status for a channel. 18 | * @param channelName 19 | * @param streamStatus 20 | * @returns 21 | */ 22 | export function setStreamStatus( 23 | channelName: string | null, 24 | streamStatus: StreamStatus 25 | ): boolean { 26 | if (!channelName) return false; 27 | store.state.streamStatuses[channelName] = streamStatus; 28 | return true; 29 | } 30 | -------------------------------------------------------------------------------- /src/common/ts/regexes.ts: -------------------------------------------------------------------------------- 1 | export const passportHostRegex = /^passport\.twitch\.tv$/i; 2 | export const twitchApiChannelNameRegex = /\/hls\/(.+)\.m3u8/i; 3 | export const twitchChannelNameRegex = 4 | /^https?:\/\/(?:www|m)\.twitch\.tv\/(?:videos\/|popout\/|moderator\/)?((?!(?:directory|downloads|jobs|p|privacy|search|settings|store|turbo)\b)\w+)/i; 5 | export const twitchGqlHostRegex = /^gql\.twitch\.tv$/i; 6 | export const twitchTvHostRegex = /^(?:www|m)\.twitch\.tv$/i; 7 | export const usherHostRegex = /^usher\.ttvnw\.net$/i; 8 | export const videoWeaverHostRegex = 9 | /^(?:[a-z0-9-]+\.playlist\.(?:live-video|ttvnw)\.net|video-weaver\.[a-z0-9-]+\.hls\.ttvnw\.net)$/i; 10 | export const videoWeaverUrlRegex = 11 | /^https?:\/\/(?:[a-z0-9-]+\.playlist\.(?:live-video|ttvnw)\.net|video-weaver\.[a-z0-9-]+\.hls\.ttvnw\.net)\/v1\/playlist\/.+\.m3u8$/gim; 12 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: ["**"] 6 | tags-ignore: ["**"] 7 | pull_request: 8 | types: [opened, synchronize, reopened, ready_for_review] 9 | branches: ["**"] 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | build: 17 | name: Build 18 | runs-on: ubuntu-latest 19 | if: ${{ !github.event.pull_request.draft }} 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Install dependencies 23 | run: npm install 24 | - name: Run linter 25 | run: npm run lint 26 | - name: Run type checker 27 | run: npm run type-check 28 | # - name: Run tests 29 | # run: npm run test 30 | - name: Build for Firefox 31 | run: npm run build:firefox 32 | - name: Clean up 33 | run: npm run clean 34 | - name: Build for Chromium 35 | run: npm run build:chromium 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Smartphone (please complete the following information):** 33 | 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /src/background/handlers/onTabRemoved.ts: -------------------------------------------------------------------------------- 1 | import areAllTabsWhitelisted from "../../common/ts/areAllTabsWhitelisted"; 2 | import isChromium from "../../common/ts/isChromium"; 3 | import { clearProxySettings } from "../../common/ts/proxySettings"; 4 | import store from "../../store"; 5 | 6 | export default function onTabRemoved(tabId: number): void { 7 | if (store.readyState !== "complete") 8 | return store.addEventListener("load", () => onTabRemoved(tabId)); 9 | 10 | const index = store.state.openedTwitchTabs.findIndex(tab => tab.id === tabId); 11 | if (index === -1) return; 12 | 13 | console.log(`➖ Closed Twitch tab: ${tabId}`); 14 | store.state.openedTwitchTabs.splice(index, 1); 15 | 16 | if (isChromium) { 17 | const allTabsAreWhitelisted = areAllTabsWhitelisted( 18 | store.state.openedTwitchTabs 19 | ); 20 | if ( 21 | (store.state.openedTwitchTabs.length === 0 || allTabsAreWhitelisted) && 22 | store.state.chromiumProxyActive 23 | ) { 24 | clearProxySettings(); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/common/ts/filterResponseDataWrapper.ts: -------------------------------------------------------------------------------- 1 | import browser, { WebRequest } from "webextension-polyfill"; 2 | 3 | export default function filterResponseDataWrapper( 4 | details: WebRequest.OnBeforeRequestDetailsType, 5 | replacer: ( 6 | responseText: string, 7 | details: WebRequest.OnBeforeRequestDetailsType 8 | ) => string 9 | ): void { 10 | if (!browser.webRequest.filterResponseData) return; 11 | 12 | const filter = browser.webRequest.filterResponseData(details.requestId); 13 | const decoder = new TextDecoder("utf-8"); 14 | const encoder = new TextEncoder(); 15 | 16 | const buffers = [] as ArrayBuffer[]; 17 | filter.ondata = event => buffers.push(event.data); 18 | filter.onstop = () => { 19 | let responseText = ""; 20 | for (const [i, buffer] of buffers.entries()) { 21 | const stream = i !== buffers.length - 1; 22 | responseText += decoder.decode(buffer, { stream }); 23 | } 24 | responseText = replacer(responseText, details); 25 | 26 | filter.write(encoder.encode(responseText)); 27 | filter.close(); 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/background/handlers/onBeforeSendHeaders.ts: -------------------------------------------------------------------------------- 1 | import { WebRequest } from "webextension-polyfill"; 2 | import acceptFlag from "../../common/ts/acceptFlag"; 3 | import isFlaggedRequest from "../../common/ts/isFlaggedRequest"; 4 | 5 | export default function onBeforeSendHeaders( 6 | details: WebRequest.OnBeforeSendHeadersDetailsType 7 | ): WebRequest.BlockingResponse | Promise { 8 | if (isFlaggedRequest(details.requestHeaders)) { 9 | console.log(`🔎 Found flagged request for ${details.url}, removing flag…`); 10 | return { 11 | requestHeaders: details.requestHeaders!.reduce((acc, curr) => { 12 | if (curr.name.toLowerCase() === "accept") { 13 | if (curr.value === acceptFlag) return acc; // Remove header. 14 | acc.push({ 15 | name: curr.name, 16 | value: curr.value?.replace(acceptFlag, ""), 17 | }); 18 | return acc; 19 | } 20 | acc.push(curr); 21 | return acc; 22 | }, [] as WebRequest.HttpHeaders), 23 | }; 24 | } 25 | return {}; 26 | } 27 | -------------------------------------------------------------------------------- /src/common/css/boilerplate.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | src: url("../fonts/Inter-VariableFont_slnt\,wght.ttf"); 3 | font-family: "Inter"; 4 | } /* Project-specific font definition. */ 5 | 6 | *, 7 | *::before, 8 | *::after { 9 | -moz-box-sizing: border-box; 10 | -webkit-box-sizing: border-box; 11 | box-sizing: border-box; 12 | } 13 | 14 | html { 15 | min-height: 100vh; /* Fixes https://stackoverflow.com/questions/59823681/webapp-on-iphone-with-black-translucent-statusbar-viewport-height-seems-to-be-w */ 16 | font-size: 100%; /* See https://whitep4nth3r.com/blog/how-to-make-your-font-sizes-accessible-with-css/#set-font-size-100-on-the-html-tag */ 17 | } 18 | 19 | body { 20 | -webkit-text-size-adjust: 100%; /* Fixes https://stackoverflow.com/questions/2710764/preserve-html-font-size-when-iphone-orientation-changes-from-portrait-to-landsca */ 21 | text-size-adjust: 100%; 22 | } 23 | 24 | input, 25 | textarea, 26 | select, 27 | button, 28 | option { 29 | font-family: inherit; /* Fixes https://stackoverflow.com/questions/26140050/why-is-font-family-not-inherited-in-button-tags-automatically */ 30 | } 31 | -------------------------------------------------------------------------------- /src/background/handlers/onInstalled.ts: -------------------------------------------------------------------------------- 1 | import setupPageURL from "url:../../setup/page.html"; 2 | import browser, { Runtime } from "webextension-polyfill"; 3 | import store from "../../store"; 4 | 5 | export default function onInstalled( 6 | details: Runtime.OnInstalledDetailsType 7 | ): void { 8 | if (store.readyState !== "complete") 9 | return store.addEventListener("load", () => onInstalled(details)); 10 | 11 | if (details.reason === "update") { 12 | // Remove ad log entries from previous versions. 13 | store.state.adLog = store.state.adLog.filter(entry => "rawLine" in entry); 14 | } 15 | 16 | // Open the setup page on first install. 17 | const isDevelopment = process.env.NODE_ENV === "development"; 18 | if (!isDevelopment && details.reason === "install") { 19 | const currentSetupVersion = 1; // Careful! Increasing this number will trigger the setup page to open for everyone. 20 | if (store.state.completedSetupVersion < currentSetupVersion) { 21 | store.state.completedSetupVersion = currentSetupVersion; 22 | browser.tabs.create({ 23 | url: `${setupPageURL}?reason=${details.reason}`, 24 | }); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/common/ts/file.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Read a file from the user's computer. 3 | * @param accept 4 | * @returns 5 | */ 6 | export async function readFile(accept = "text/plain;charset=utf-8") { 7 | return new Promise((resolve, reject) => { 8 | const input = document.createElement("input"); 9 | input.type = "file"; 10 | input.accept = accept; 11 | input.addEventListener("change", async e => { 12 | const input = e.target as HTMLInputElement; 13 | const file = input.files?.[0]; 14 | if (!file) return reject("No file selected"); 15 | const data = await file.text(); 16 | return resolve(data); 17 | }); 18 | input.click(); 19 | input.remove(); 20 | }); 21 | } 22 | 23 | /** 24 | * Save a file to the user's computer. 25 | * @param filename 26 | * @param content 27 | * @param type 28 | */ 29 | export function saveFile( 30 | filename: string, 31 | content: string, 32 | type = "text/plain;charset=utf-8" 33 | ) { 34 | const a = document.createElement("a"); 35 | a.setAttribute("href", `data:${type},` + encodeURIComponent(content)); 36 | a.setAttribute("download", filename); 37 | a.click(); 38 | a.remove(); 39 | } 40 | -------------------------------------------------------------------------------- /src/background/handlers/onStartupStoreCleanup.ts: -------------------------------------------------------------------------------- 1 | import store from "../../store"; 2 | 3 | /* 4 | * WHY ARE WE DOING THIS? 5 | * 6 | * Since `browser.storage.session` is not supported in all browsers, we use 7 | * `browser.storage.local` instead. This means that the session-related data 8 | * (e.g. stream statuses) will persist across browser sessions. This function 9 | * cleans up the session-related data on startup. 10 | */ 11 | export default function onStartupStoreCleanup(): void { 12 | if (store.readyState !== "complete") 13 | return store.addEventListener("load", () => onStartupStoreCleanup()); 14 | 15 | const now = Date.now(); 16 | store.state.adLog = store.state.adLog.filter( 17 | entry => now - entry.timestamp < 1000 * 60 * 60 * 24 * 7 // 7 days 18 | ); 19 | const maxAdLogEntries = 100; 20 | if (store.state.adLog.length > maxAdLogEntries) { 21 | store.state.adLog.splice(0, store.state.adLog.length - maxAdLogEntries); 22 | } 23 | store.state.chromiumProxyActive = false; 24 | store.state.dnsResponses = []; 25 | store.state.openedTwitchTabs = []; 26 | store.state.streamStatuses = {}; 27 | store.state.videoWeaverUrlsByChannel = {}; 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. 4 | # 5 | # Source repository: https://github.com/actions/dependency-review-action 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 7 | name: "Dependency Review" 8 | on: 9 | pull_request: 10 | types: [opened, synchronize, reopened, ready_for_review] 11 | branches: ["**"] 12 | 13 | permissions: 14 | contents: read 15 | 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.ref }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | dependency-review: 22 | name: Dependency Review 23 | runs-on: ubuntu-latest 24 | if: ${{ !github.event.pull_request.draft }} 25 | steps: 26 | - name: "Checkout Repository" 27 | uses: actions/checkout@v4 28 | - name: "Dependency Review" 29 | uses: actions/dependency-review-action@v4 30 | -------------------------------------------------------------------------------- /src/background/handlers/onTabCreated.ts: -------------------------------------------------------------------------------- 1 | import { Tabs } from "webextension-polyfill"; 2 | import findChannelFromTwitchTvUrl from "../../common/ts/findChannelFromTwitchTvUrl"; 3 | import getHostFromUrl from "../../common/ts/getHostFromUrl"; 4 | import isChannelWhitelisted from "../../common/ts/isChannelWhitelisted"; 5 | import isChromium from "../../common/ts/isChromium"; 6 | import { updateProxySettings } from "../../common/ts/proxySettings"; 7 | import { twitchTvHostRegex } from "../../common/ts/regexes"; 8 | import store from "../../store"; 9 | 10 | export default function onTabCreated(tab: Tabs.Tab): void { 11 | if (store.readyState !== "complete") 12 | return store.addEventListener("load", () => onTabCreated(tab)); 13 | 14 | const url = tab.url || tab.pendingUrl; 15 | if (!url) return; 16 | const host = getHostFromUrl(url); 17 | if (!host) return; 18 | 19 | if (twitchTvHostRegex.test(host)) { 20 | console.log(`➕ Opened Twitch tab: ${tab.id}`); 21 | store.state.openedTwitchTabs.push(tab); 22 | 23 | if (isChromium) { 24 | const channelName = findChannelFromTwitchTvUrl(url); 25 | const isWhitelisted = channelName 26 | ? isChannelWhitelisted(channelName) 27 | : false; 28 | if (!isWhitelisted && !store.state.chromiumProxyActive) { 29 | updateProxySettings(); 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/background/handlers/checkForOpenedTwitchTabs.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | import areAllTabsWhitelisted from "../../common/ts/areAllTabsWhitelisted"; 3 | import isChromium from "../../common/ts/isChromium"; 4 | import { 5 | clearProxySettings, 6 | updateProxySettings, 7 | } from "../../common/ts/proxySettings"; 8 | import store from "../../store"; 9 | 10 | export default async function checkForOpenedTwitchTabs() { 11 | // Wait for the store to be loaded. 12 | if (store.readyState !== "complete") { 13 | await new Promise(resolve => { 14 | const listener = () => { 15 | store.removeEventListener("load", listener); 16 | resolve(); 17 | }; 18 | store.addEventListener("load", listener); 19 | }); 20 | } 21 | 22 | try { 23 | const tabs = await browser.tabs.query({ 24 | url: ["https://www.twitch.tv/*", "https://m.twitch.tv/*"], 25 | }); 26 | console.log(`🔍 Found ${tabs.length} opened Twitch tabs.`); 27 | store.state.openedTwitchTabs = tabs; 28 | 29 | if (isChromium) { 30 | const allTabsAreWhitelisted = areAllTabsWhitelisted(tabs); 31 | if (tabs.length > 0 && !allTabsAreWhitelisted) { 32 | updateProxySettings(); 33 | } else { 34 | clearProxySettings(); 35 | } 36 | } 37 | } catch (error) { 38 | console.error(`❌ Failed to query opened Twitch tabs: ${error}`); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/store/types.ts: -------------------------------------------------------------------------------- 1 | import type { Tabs } from "webextension-polyfill"; 2 | import type { 3 | AdLogEntry, 4 | DnsResponse, 5 | PassportConfig, 6 | StreamStatus, 7 | UserExperienceMode, 8 | } from "../types"; 9 | 10 | export type EventType = "load" | "change"; 11 | export type ReadyState = "loading" | "complete"; 12 | export type StorageAreaName = "local" | "managed" | "sync"; 13 | 14 | export interface State { 15 | activeChannelSubscriptions: string[]; 16 | adLog: AdLogEntry[]; 17 | adLogEnabled: boolean; 18 | adLogLastSent: number; 19 | allowOtherProxyProtocols: boolean; 20 | anonymousMode: boolean; 21 | chromiumProxyActive: boolean; 22 | completedSetupVersion: number; 23 | customPassport: PassportConfig; 24 | customPassportEnabled: boolean; 25 | dnsResponses: DnsResponse[]; 26 | normalProxies: string[]; 27 | openedTwitchTabs: Tabs.Tab[]; 28 | optimizedProxies: string[]; 29 | optimizedProxiesEnabled: boolean; 30 | passportLevel: number; 31 | streamStatuses: Record; 32 | userExperienceMode: UserExperienceMode; 33 | userExperienceOverridenOptions: Partial< 34 | Omit 35 | >; 36 | videoWeaverUrlsByChannel: Record; 37 | whitelistChannelSubscriptions: boolean; 38 | whitelistedChannels: string[]; 39 | } 40 | 41 | export const enum ProxyFlags { 42 | IS_PROXY = "__isProxy", 43 | RAW = "__raw", 44 | } 45 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # Privacy policy 2 | 3 | ## Proxy Services & Data Security 4 | 5 | TTV LOL PRO routes certain Twitch requests through proxy servers using industry-standard protocols[^1]. Due to HTTPS encryption, the actual content of these requests remains unreadable and unmodifiable by the proxies. However, the proxies can still see the domain names associated with the requests. 6 | 7 | ## Default Proxy Logging 8 | 9 | TTV LOL PRO includes default proxies that redirect Twitch traffic through ad-free regions. To prevent abuse and ensure service quality, these proxies may temporarily log IP addresses for up to 24 hours. These logs are strictly for security and operational purposes and are never sold or shared with third parties. 10 | 11 | ## Third-Party Proxy Use 12 | 13 | Users who opt to use third-party proxies should be aware that these services operate under their own privacy policies, independent of TTV LOL PRO. Since TTV LOL PRO does not control these external providers, users are encouraged to review their policies before use. 14 | 15 | ## Local Ad-Bypass Logging 16 | 17 | TTV LOL PRO logs instances where ads bypass the ad-blocking mechanism. These logs are stored locally on the user's device and can be viewed, disabled, or cleared through the extension’s settings. Users may choose to send these logs to the TTV LOL PRO developers for analysis. To minimize storage, log entries are automatically deleted after seven days. 18 | 19 | [^1]: HTTP, HTTPS, SOCKS4, and SOCKS5 20 | -------------------------------------------------------------------------------- /src/m3u8-parser.d.ts: -------------------------------------------------------------------------------- 1 | declare module "m3u8-parser" { 2 | // https://github.com/videojs/m3u8-parser#parsed-output 3 | interface Manifest { 4 | allowCache: boolean; 5 | endList?: boolean; 6 | mediaSequence?: number; 7 | dateRanges: { [key: string]: unknown }[]; 8 | discontinuitySequence?: number; 9 | playlistType?: string; 10 | custom?: {}; 11 | playlists?: { 12 | attributes: { [key: string]: unknown }; 13 | uri: string; 14 | timeline: number; 15 | }[]; 16 | mediaGroups?: { 17 | AUDIO: {}; 18 | VIDEO: {}; 19 | "CLOSED-CAPTIONS": {}; 20 | SUBTITLES: {}; 21 | }; 22 | dateTimeString?: string; 23 | dateTimeObject?: Date; 24 | targetDuration?: number; 25 | totalDuration?: number; 26 | discontinuityStarts: number[]; 27 | segments: { 28 | title: string; 29 | byterange: { 30 | length: number; 31 | offset: number; 32 | }; 33 | duration: number; 34 | programDateTime: number; 35 | attributes: {}; 36 | discontinuity: number; 37 | uri: string; 38 | timeline: number; 39 | key: { 40 | method: string; 41 | uri: string; 42 | iv: string; 43 | }; 44 | map: { 45 | uri: string; 46 | byterange: { 47 | length: number; 48 | offset: number; 49 | }; 50 | }; 51 | "cue-out": string; 52 | "cue-out-cont": string; 53 | "cue-in": string; 54 | custom: {}; 55 | }[]; 56 | } 57 | 58 | export class Parser { 59 | constructor(); 60 | push(chunk: string): void; 61 | end(): void; 62 | manifest: Manifest; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/store/handlers/getStateHandler.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | import { ProxyFlags, StorageAreaName } from "../types"; 3 | import { toRaw } from "../utils"; 4 | import getPropertyHandler from "./getPropertyHandler"; 5 | 6 | export default function getStateHandler>( 7 | areaName: StorageAreaName, 8 | state: T 9 | ): ProxyHandler { 10 | const stateHandler: ProxyHandler = { 11 | defineProperty: (target, key: keyof T, descriptor) => { 12 | const rawDescriptor = toRaw(descriptor); 13 | target[key] = rawDescriptor; 14 | browser.storage[areaName] 15 | .set({ [key]: rawDescriptor }) 16 | .catch(console.error); 17 | return true; 18 | }, 19 | deleteProperty: (target, property) => { 20 | delete target[property]; 21 | browser.storage[areaName] 22 | .remove(property.toString()) 23 | .catch(console.error); 24 | return true; 25 | }, 26 | get: (target, property) => { 27 | if (property === ProxyFlags.IS_PROXY) return true; 28 | if (property === ProxyFlags.RAW) return target; 29 | if (typeof target[property] === "object" && target[property] !== null) { 30 | const propertyHandler = getPropertyHandler(areaName, state, property); 31 | return new Proxy(target[property], propertyHandler); 32 | } else return target[property]; 33 | }, 34 | set: (target, property: keyof T, value) => { 35 | const rawValue = toRaw(value); 36 | target[property] = rawValue; 37 | browser.storage[areaName] 38 | .set({ [property]: rawValue }) 39 | .catch(console.error); 40 | return true; 41 | }, 42 | }; 43 | return stateHandler; 44 | } 45 | -------------------------------------------------------------------------------- /src/page/types.ts: -------------------------------------------------------------------------------- 1 | import { Mutex } from "async-mutex"; 2 | import type { State } from "../store/types"; 3 | import { MessageType, ProxyRequestType } from "../types"; 4 | 5 | export type SendMessageFn = (message: any) => void; 6 | export type SendMessageAndWaitForResponseFn = ( 7 | scope: "page" | "worker", 8 | message: any, 9 | responseMessageType: MessageType, 10 | responseTimeout?: number 11 | ) => Promise; 12 | 13 | export interface PageState { 14 | params: any; 15 | isChromium: boolean; 16 | scope: "page" | "worker"; 17 | state?: State; 18 | requestTypeMutexes: Record; 19 | twitchWorkers: Worker[]; 20 | sendMessageToContentScript: SendMessageFn; 21 | sendMessageToContentScriptAndWaitForResponse: SendMessageAndWaitForResponseFn; 22 | sendMessageToPageScript: SendMessageFn; 23 | sendMessageToPageScriptAndWaitForResponse: SendMessageAndWaitForResponseFn; 24 | sendMessageToWorkerScripts: SendMessageFn; 25 | sendMessageToWorkerScriptsAndWaitForResponse: SendMessageAndWaitForResponseFn; 26 | } 27 | 28 | export interface UsherManifest { 29 | channelName: string | null; 30 | assignedMap: Map; // E.g. "720p60" -> "https://video-weaver.fra02.hls.ttvnw.net/v1/playlist/..." 31 | replacementMap: Map | null; // Same as above, but with new URLs. 32 | consecutiveAdResponses: number; // Used to avoid infinite loops. 33 | consecutiveAdCooldown: number; // Used to avoid infinite loops. 34 | deleted: boolean; // Deletion flag for cleanup. 35 | } 36 | 37 | export interface PlaybackAccessToken { 38 | value: string; 39 | signature: string; 40 | authorization: { 41 | isForbidden: boolean; 42 | forbiddenReasonCode: string; 43 | }; 44 | __typename: string; 45 | } 46 | -------------------------------------------------------------------------------- /src/background/handlers/onAuthRequired.ts: -------------------------------------------------------------------------------- 1 | import { WebRequest } from "webextension-polyfill"; 2 | import { getProxyInfoFromUrl } from "../../common/ts/proxyInfo"; 3 | import store from "../../store"; 4 | 5 | const pendingRequests: string[] = []; 6 | 7 | export default function onAuthRequired( 8 | details: WebRequest.OnAuthRequiredDetailsType 9 | ): WebRequest.BlockingResponseOrPromiseOrVoid { 10 | if (!details.isProxy) return; 11 | 12 | if (pendingRequests.includes(details.requestId)) { 13 | console.error( 14 | `🔒 Provided invalid credentials for proxy ${details.challenger.host}:${details.challenger.port}.` 15 | ); 16 | // TODO: Remove proxy from list of available proxies (for fallback system). 17 | return; 18 | } 19 | pendingRequests.push(details.requestId); 20 | 21 | const proxies = store.state.optimizedProxiesEnabled 22 | ? store.state.optimizedProxies 23 | : store.state.normalProxies; 24 | const proxy = proxies.find(proxy => { 25 | const proxyInfo = getProxyInfoFromUrl(proxy); 26 | return ( 27 | proxyInfo.host === details.challenger.host && 28 | proxyInfo.port === details.challenger.port 29 | ); 30 | }); 31 | if (!proxy) { 32 | console.error( 33 | `❌ Proxy ${details.challenger.host}:${details.challenger.port} not found.` 34 | ); 35 | return; 36 | } 37 | 38 | const proxyInfo = getProxyInfoFromUrl(proxy); 39 | if (proxyInfo.username == null || proxyInfo.password == null) { 40 | console.error(`❌ No credentials provided for proxy ${proxy}.`); 41 | return; 42 | } 43 | 44 | console.log(`🔑 Providing credentials for proxy ${proxy}.`); 45 | return { 46 | authCredentials: { 47 | username: proxyInfo.username, 48 | password: proxyInfo.password, 49 | }, 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/manifest.chromium.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "TTV LOL PRO", 4 | "description": "TTV LOL PRO removes most livestream ads from Twitch.", 5 | "homepage_url": "https://github.com/younesaassila/ttv-lol-pro", 6 | "version": "2.6.1", 7 | "background": { 8 | "service_worker": "background/background.ts", 9 | "type": "module" 10 | }, 11 | "declarative_net_request": { 12 | "rule_resources": [ 13 | { 14 | "id": "ruleset", 15 | "enabled": true, 16 | "path": "rulesets/ruleset.json" 17 | } 18 | ] 19 | }, 20 | "action": { 21 | "default_icon": { 22 | "128": "common/images/brand/icon.png" 23 | }, 24 | "default_title": "TTV LOL PRO", 25 | "default_popup": "popup/menu.html" 26 | }, 27 | "content_scripts": [ 28 | { 29 | "matches": ["https://www.twitch.tv/*", "https://m.twitch.tv/*"], 30 | "js": ["content/content.ts"], 31 | "run_at": "document_start" 32 | }, 33 | { 34 | "matches": ["https://www.twitch.tv/*", "https://m.twitch.tv/*"], 35 | "js": ["page/page.ts"], 36 | "run_at": "document_start", 37 | "world": "MAIN" 38 | } 39 | ], 40 | "icons": { 41 | "128": "common/images/brand/icon.png" 42 | }, 43 | "options_ui": { 44 | "open_in_tab": true, 45 | "page": "options/page.html" 46 | }, 47 | "permissions": [ 48 | "declarativeNetRequest", 49 | "proxy", 50 | "storage", 51 | "tabs", 52 | "webRequest", 53 | "webRequestAuthProvider" 54 | ], 55 | "host_permissions": [ 56 | "https://*.live-video.net/*", 57 | "https://*.ttvnw.net/*", 58 | "https://*.twitch.tv/*" 59 | ], 60 | "minimum_chrome_version": "111", 61 | "web_accessible_resources": [ 62 | { 63 | "resources": ["setup/page.html"], 64 | "matches": [], 65 | "use_dynamic_url": true 66 | } 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /src/manifest.firefox.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "TTV LOL PRO", 4 | "description": "TTV LOL PRO removes most livestream ads from Twitch.", 5 | "homepage_url": "https://github.com/younesaassila/ttv-lol-pro", 6 | "version": "2.6.1", 7 | "background": { 8 | "scripts": ["background/background.ts"], 9 | "persistent": false 10 | }, 11 | "browser_action": { 12 | "default_icon": { 13 | "128": "common/images/brand/icon.png" 14 | }, 15 | "default_title": "TTV LOL PRO", 16 | "default_popup": "popup/menu.html" 17 | }, 18 | "browser_specific_settings": { 19 | "gecko": { 20 | "id": "{76ef94a4-e3d0-4c6f-961a-d38a429a332b}", 21 | "strict_min_version": "128.0", 22 | "data_collection_permissions": { 23 | "required": ["none"], 24 | "optional": ["websiteContent"] 25 | } 26 | }, 27 | "gecko_android": { 28 | "strict_min_version": "128.0" 29 | } 30 | }, 31 | "content_scripts": [ 32 | { 33 | "matches": ["https://www.twitch.tv/*", "https://m.twitch.tv/*"], 34 | "js": ["content/content.ts"], 35 | "run_at": "document_start" 36 | }, 37 | { 38 | "matches": ["https://www.twitch.tv/*", "https://m.twitch.tv/*"], 39 | "js": ["page/page.ts"], 40 | "run_at": "document_start", 41 | "world": "MAIN" 42 | } 43 | ], 44 | "icons": { 45 | "128": "common/images/brand/icon.png" 46 | }, 47 | "options_ui": { 48 | "browser_style": false, 49 | "open_in_tab": true, 50 | "page": "options/page.html" 51 | }, 52 | "permissions": [ 53 | "proxy", 54 | "storage", 55 | "webRequest", 56 | "webRequestBlocking", 57 | "https://*.live-video.net/*", 58 | "https://*.ttvnw.net/*", 59 | "https://*.twitch.tv/*", 60 | "https://perfprod.com/ttvlolpro/telemetry" 61 | ], 62 | "web_accessible_resources": ["setup/page.html"] 63 | } 64 | -------------------------------------------------------------------------------- /src/setup/setup.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | import $ from "../common/ts/$"; 3 | import setUserExperienceMode from "../common/ts/setUserExperienceMode"; 4 | import store from "../store"; 5 | import type { UserExperienceMode } from "../types"; 6 | 7 | const setupFormElement = $("#setup-form") as HTMLFormElement; 8 | const expertModeSegmentElement = $("#expert-mode-segment") as HTMLDivElement; 9 | 10 | if (store.readyState === "complete") init(); 11 | else store.addEventListener("load", init); 12 | 13 | function init() { 14 | const experienceRadioNodeList = setupFormElement.elements.namedItem( 15 | "experience" 16 | ) as RadioNodeList | null; 17 | if (!experienceRadioNodeList) { 18 | const message = "Experience radio buttons not found in setup form."; 19 | console.error(message); 20 | alert(message); 21 | return; 22 | } 23 | experienceRadioNodeList.value = store.state.userExperienceMode; 24 | if (store.state.userExperienceMode === "expertMode") { 25 | expertModeSegmentElement.removeAttribute("hidden"); 26 | } 27 | } 28 | 29 | setupFormElement.addEventListener("change", event => { 30 | if (!(event.target instanceof HTMLInputElement)) return; 31 | if (event.target.name !== "experience") return; 32 | const experienceMode = event.target.value as UserExperienceMode; 33 | if (experienceMode === "expertMode") { 34 | expertModeSegmentElement.removeAttribute("hidden"); 35 | } 36 | setUserExperienceMode(experienceMode); 37 | }); 38 | 39 | setupFormElement.addEventListener("submit", async event => { 40 | event.preventDefault(); 41 | // Close the current tab. 42 | try { 43 | const tabs = await browser.tabs.query({ 44 | active: true, 45 | currentWindow: true, 46 | }); 47 | if (tabs.length > 0) { 48 | browser.tabs.remove(tabs[0].id!); 49 | } 50 | } catch (error) { 51 | const message = `Failed to close the current tab: ${error}`; 52 | console.error(message); 53 | alert(message); 54 | } 55 | }); 56 | -------------------------------------------------------------------------------- /src/store/getDefaultState.ts: -------------------------------------------------------------------------------- 1 | import isChromium from "../common/ts/isChromium"; 2 | import type { State } from "./types"; 3 | 4 | export default function getDefaultState() { 5 | const state: State = { 6 | activeChannelSubscriptions: [], 7 | adLog: [], 8 | adLogEnabled: true, 9 | adLogLastSent: 0, 10 | allowOtherProxyProtocols: false, 11 | anonymousMode: true, 12 | chromiumProxyActive: false, 13 | completedSetupVersion: 0, 14 | customPassport: { 15 | passport: false, 16 | usher: false, 17 | videoWeaver: false, 18 | graphQLToken: false, 19 | graphQLIntegrity: false, 20 | graphQLAll: false, 21 | twitchWebpage: false, 22 | }, 23 | customPassportEnabled: false, 24 | dnsResponses: [], 25 | normalProxies: getDefaultNormalProxies(), 26 | openedTwitchTabs: [], 27 | optimizedProxies: getDefaultOptimizedProxies(), 28 | optimizedProxiesEnabled: true, 29 | passportLevel: 0, 30 | streamStatuses: {}, 31 | userExperienceMode: "blockAds", 32 | userExperienceOverridenOptions: {}, 33 | videoWeaverUrlsByChannel: {}, 34 | whitelistChannelSubscriptions: true, 35 | whitelistedChannels: [], 36 | }; 37 | return state; 38 | } 39 | 40 | function getDefaultNormalProxies(): string[] { 41 | const normalProxies: string[] = []; 42 | if ( 43 | process.env.NODE_ENV === "development" && 44 | process.env.DEV_NORMAL_PROXIES 45 | ) { 46 | normalProxies.unshift( 47 | ...process.env.DEV_NORMAL_PROXIES.split(",") 48 | .map(s => s.trim()) 49 | .filter(s => !!s) 50 | ); 51 | } 52 | return normalProxies; 53 | } 54 | 55 | function getDefaultOptimizedProxies(): string[] { 56 | const optimizedProxies: string[] = isChromium 57 | ? ["chromium.api.cdn-perfprod.com:2023"] 58 | : ["firefox.api.cdn-perfprod.com:2023"]; 59 | if ( 60 | process.env.NODE_ENV === "development" && 61 | process.env.DEV_OPTIMIZED_PROXIES 62 | ) { 63 | optimizedProxies.unshift( 64 | ...process.env.DEV_OPTIMIZED_PROXIES.split(",") 65 | .map(s => s.trim()) 66 | .filter(s => !!s) 67 | ); 68 | } 69 | return optimizedProxies; 70 | } 71 | -------------------------------------------------------------------------------- /src/store/handlers/getPropertyHandler.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | import { ProxyFlags, StorageAreaName } from "../types"; 3 | import { toRaw } from "../utils"; 4 | 5 | export default function getPropertyHandler< 6 | T extends Record, 7 | >( 8 | areaName: StorageAreaName, 9 | state: T, 10 | property: string | symbol 11 | ): ProxyHandler> { 12 | const propertyHandler: ProxyHandler> = { 13 | defineProperty: (propertyObj, subproperty, subpropertyDescriptor) => { 14 | const rawSubpropertyDescriptor = toRaw(subpropertyDescriptor); 15 | propertyObj[subproperty] = rawSubpropertyDescriptor; 16 | browser.storage[areaName] 17 | .set({ [property]: state[property] }) 18 | .catch(console.error); 19 | return true; 20 | }, 21 | deleteProperty: (propertyObj, subproperty) => { 22 | delete propertyObj[subproperty]; 23 | browser.storage[areaName] 24 | .set({ [property]: state[property] }) 25 | .catch(console.error); 26 | return true; 27 | }, 28 | get: (propertyObj, subproperty) => { 29 | if (subproperty === ProxyFlags.IS_PROXY) return true; 30 | if (subproperty === ProxyFlags.RAW) return propertyObj; 31 | const subpropertyValue = propertyObj[subproperty]; 32 | const containsObjects = (parent: object) => 33 | Object.values(parent).some( 34 | childValue => typeof childValue === "object" && childValue !== null 35 | ); 36 | if ( 37 | typeof subpropertyValue === "object" && 38 | subpropertyValue !== null && 39 | containsObjects(subpropertyValue) 40 | ) { 41 | return new Proxy(subpropertyValue, propertyHandler); 42 | } else return subpropertyValue; 43 | }, 44 | set: (propertyObj, subproperty, subpropertyValue) => { 45 | const rawSubpropertyValue = toRaw(subpropertyValue); 46 | propertyObj[subproperty] = rawSubpropertyValue; 47 | browser.storage[areaName] 48 | .set({ [property]: state[property] }) 49 | .catch(console.error); 50 | return true; 51 | }, 52 | }; 53 | return propertyHandler; 54 | } 55 | -------------------------------------------------------------------------------- /src/common/ts/setUserExperienceMode.ts: -------------------------------------------------------------------------------- 1 | import store from "../../store"; 2 | import type { ObjectEntries, UserExperienceMode } from "../../types"; 3 | import isChromium from "./isChromium"; 4 | import { updateProxySettings } from "./proxySettings"; 5 | 6 | /** 7 | * Safely set the user experience mode and override options accordingly. 8 | * @param experienceMode 9 | */ 10 | export default function setUserExperienceMode( 11 | experienceMode: UserExperienceMode 12 | ) { 13 | if (experienceMode === store.state.userExperienceMode) return; 14 | 15 | // Restore overridden options to the state. 16 | const typedEntries = Object.entries( 17 | store.state.userExperienceOverridenOptions 18 | ) as ObjectEntries; 19 | for (const [key, value] of typedEntries) { 20 | if (key in store.state && value !== undefined) { 21 | (store.state as any)[key] = value; 22 | } 23 | } 24 | store.state.userExperienceOverridenOptions = {}; 25 | 26 | switch (experienceMode) { 27 | case "blockAds": 28 | store.state.customPassportEnabled = false; 29 | break; 30 | case "unlockBestQuality": 31 | store.state.customPassportEnabled = true; 32 | // Backup options to be overridden. 33 | store.state.userExperienceOverridenOptions = { 34 | adLogEnabled: store.state.adLogEnabled, 35 | anonymousMode: store.state.anonymousMode, 36 | customPassport: store.state.customPassport, 37 | whitelistChannelSubscriptions: 38 | store.state.whitelistChannelSubscriptions, 39 | }; 40 | store.state.adLogEnabled = false; 41 | store.state.anonymousMode = false; 42 | store.state.customPassport = { 43 | passport: false, 44 | usher: true, 45 | videoWeaver: false, 46 | graphQLToken: true, 47 | graphQLIntegrity: false, 48 | graphQLAll: false, 49 | twitchWebpage: false, 50 | }; 51 | store.state.whitelistChannelSubscriptions = false; 52 | break; 53 | case "expertMode": 54 | store.state.customPassportEnabled = true; 55 | break; 56 | } 57 | 58 | store.state.userExperienceMode = experienceMode; 59 | if (isChromium && store.state.chromiumProxyActive) { 60 | updateProxySettings(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in contributing to TTV LOL PRO! The extension is written in [TypeScript](https://www.typescriptlang.org/) and uses the [`webextension-polyfill`](https://www.npmjs.com/package/webextension-polyfill) npm package. The build process is handled by [Parcel](https://parceljs.org/) via its [`@parcel/config-webextension`](https://www.npmjs.com/package/@parcel/config-webextension) plugin. 4 | 5 | ## Requirements 6 | 7 | - [Node.js](https://nodejs.org/en) 8 | - [npm](https://www.npmjs.com/) _(Bundled with Node.js)_ 9 | 10 | ## Setup 11 | 12 | ### Installation 13 | 14 | To install the dependencies, run the following command: 15 | 16 | ```sh 17 | npm ci 18 | ``` 19 | 20 | ### Environment Variables 21 | 22 | Copy the `.env.development.example` file and rename it to `.env.development.local`. Then, fill in the optional environment variables as needed: 23 | 24 | - `DEV_OPTIMIZED_PROXIES`: Comma-separated list of proxy servers to prepend to the default 'Proxy ad requests only' list during development. 25 | - `DEV_NORMAL_PROXIES`: Comma-separated list of proxy servers to append to the default 'Proxy all requests' list during development. 26 | - `BETA`: Number indicating the beta version of the extension. If set, the extension will include the beta version in its name (e.g., "TTV LOL PRO Beta 1"). 27 | 28 | ## Development 29 | 30 | To start the file watcher and build the extension in development mode, run the following command: 31 | 32 | - For Firefox: 33 | 34 | ```sh 35 | npm run dev:firefox 36 | ``` 37 | 38 | - For Chromium-based browsers: 39 | 40 | ```sh 41 | npm run dev:chromium 42 | ``` 43 | 44 | ## Type checking 45 | 46 | To check for type errors, run the following command: 47 | 48 | ```sh 49 | npm run type-check 50 | ``` 51 | 52 | ## Linting 53 | 54 | To check for linting errors, run the following command: 55 | 56 | ```sh 57 | npm run lint 58 | ``` 59 | 60 | To automatically fix linting errors, run the following command: 61 | 62 | ```sh 63 | npm run lint:fix 64 | ``` 65 | 66 | ## Build 67 | 68 | To build the extension for production, run the following command: 69 | 70 | - For Firefox: 71 | 72 | ```sh 73 | npm run build:firefox 74 | ``` 75 | 76 | - For Chromium-based browsers: 77 | 78 | ```sh 79 | npm run build:chromium 80 | ``` 81 | 82 | ## Pull requests 83 | 84 | Before submitting a pull request, please ensure that: 85 | 86 | - Your code follows the existing coding style and conventions. 87 | - You have tested your changes thoroughly. 88 | - You have updated the documentation as needed. 89 | 90 | We appreciate your contributions and look forward to reviewing your pull requests! 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ttv-lol-pro", 3 | "version": "2.6.1", 4 | "description": "TTV LOL PRO removes most livestream ads from Twitch.", 5 | "keywords": [ 6 | "twitch", 7 | "web-extension", 8 | "adblocker" 9 | ], 10 | "homepage": "https://github.com/younesaassila/ttv-lol-pro#readme", 11 | "bugs": { 12 | "url": "https://github.com/younesaassila/ttv-lol-pro/issues" 13 | }, 14 | "license": "GPL-3.0", 15 | "author": "Younes Aassila (https://github.com/younesaassila)", 16 | "contributors": [ 17 | "Marc Gómez (https://github.com/zGato)" 18 | ], 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/younesaassila/ttv-lol-pro.git" 22 | }, 23 | "scripts": { 24 | "lint": "prettier --check .", 25 | "lint:fix": "prettier --write .", 26 | "type-check": "tsc --noEmit", 27 | "clean": "shx rm -rf .parcel-cache ; shx rm -rf dist ; shx --silent rm src/manifest.json ; exit 0", 28 | "predev:firefox": "npm run clean && shx cp src/manifest.firefox.json src/manifest.json", 29 | "predev:chromium": "npm run clean && shx cp src/manifest.chromium.json src/manifest.json", 30 | "dev:firefox": "parcel watch --target webext-dev --no-hmr", 31 | "dev:chromium": "parcel watch --target webext-dev --no-hmr", 32 | "prebuild:firefox": "npm run clean && shx cp src/manifest.firefox.json src/manifest.json", 33 | "prebuild:chromium": "npm run clean && shx cp src/manifest.chromium.json src/manifest.json", 34 | "build:firefox": "parcel build --target webext-prod", 35 | "build:chromium": "parcel build --target webext-prod" 36 | }, 37 | "dependencies": { 38 | "async-mutex": "^0.5.0", 39 | "bowser": "^2.13.1", 40 | "ip-address": "^10.1.0", 41 | "m3u8-parser": "^7.2.0" 42 | }, 43 | "devDependencies": { 44 | "@parcel/config-webextension": "^2.16.3", 45 | "@types/chrome": "^0.1.32", 46 | "@types/node": "^24.10.1", 47 | "@types/webextension-polyfill": "^0.12.4", 48 | "buffer": "^6.0.3", 49 | "parcel": "^2.16.3", 50 | "prettier": "3.6.2", 51 | "prettier-plugin-css-order": "2.1.2", 52 | "prettier-plugin-organize-imports": "4.3.0", 53 | "shx": "^0.4.0", 54 | "typescript": "^5.9.3", 55 | "webextension-polyfill": "^0.12.0" 56 | }, 57 | "source": "src/manifest.json", 58 | "targets": { 59 | "webext-dev": {}, 60 | "webext-prod": { 61 | "sourceMap": false 62 | } 63 | }, 64 | "@parcel/bundler-default": { 65 | "minBundles": 10000000, 66 | "minBundleSize": 3000, 67 | "maxParallelRequests": 20 68 | }, 69 | "browserslist": "> 0.5%, last 2 versions, not dead", 70 | "private": true 71 | } 72 | -------------------------------------------------------------------------------- /src/background/handlers/onTabUpdated.ts: -------------------------------------------------------------------------------- 1 | import { Tabs } from "webextension-polyfill"; 2 | import areAllTabsWhitelisted from "../../common/ts/areAllTabsWhitelisted"; 3 | import getHostFromUrl from "../../common/ts/getHostFromUrl"; 4 | import isChromium from "../../common/ts/isChromium"; 5 | import { 6 | clearProxySettings, 7 | updateProxySettings, 8 | } from "../../common/ts/proxySettings"; 9 | import { twitchTvHostRegex } from "../../common/ts/regexes"; 10 | import store from "../../store"; 11 | import onTabCreated from "./onTabCreated"; 12 | import onTabRemoved from "./onTabRemoved"; 13 | 14 | export default function onTabUpdated( 15 | tabId: number, 16 | changeInfo: Tabs.OnUpdatedChangeInfoType, 17 | tab: Tabs.Tab 18 | ): void { 19 | if (store.readyState !== "complete") { 20 | return store.addEventListener("load", () => 21 | onTabUpdated(tabId, changeInfo, tab) 22 | ); 23 | } 24 | 25 | const isPageNavigation = changeInfo.url != null; 26 | // We have to check for `changeInfo.status === "loading"` because 27 | // `changeInfo.url` is incorrect when navigating from Twitch to another 28 | // website. 29 | const isPageLoad = changeInfo.status === "loading"; 30 | if (!isPageNavigation && !isPageLoad) return; 31 | 32 | const url = changeInfo.url || tab.url || tab.pendingUrl; 33 | if (!url) return; 34 | const host = getHostFromUrl(url); 35 | if (!host) return; 36 | 37 | const isTwitchTab = twitchTvHostRegex.test(host); 38 | const wasTwitchTab = store.state.openedTwitchTabs.some( 39 | tab => tab.id === tabId 40 | ); 41 | if (!isTwitchTab && !wasTwitchTab) return; 42 | 43 | // Tab created 44 | if (isTwitchTab && !wasTwitchTab) { 45 | onTabCreated(tab); 46 | } 47 | 48 | // Tab removed 49 | if (!isTwitchTab && wasTwitchTab) { 50 | onTabRemoved(tabId); 51 | } 52 | 53 | // Tab updated 54 | if (isTwitchTab && wasTwitchTab) { 55 | const index = store.state.openedTwitchTabs.findIndex( 56 | tab => tab.id === tabId 57 | ); 58 | if (index === -1) return; 59 | 60 | console.log(`🟰 Updated Twitch tab: ${tabId}`); 61 | store.state.openedTwitchTabs[index] = tab; 62 | 63 | if (isChromium) { 64 | const allTabsAreWhitelisted = areAllTabsWhitelisted( 65 | store.state.openedTwitchTabs 66 | ); 67 | // We don't check for `store.state.openedTwitchTabs.length === 0` because 68 | // there is always at least one tab open (the one that triggered this 69 | // event). 70 | if (!allTabsAreWhitelisted && !store.state.chromiumProxyActive) { 71 | updateProxySettings(); 72 | } else if (allTabsAreWhitelisted && store.state.chromiumProxyActive) { 73 | clearProxySettings(); 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/setup/page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Quick Setup - TTV LOL PRO 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 | 19 |

TTV LOL PRO - Quick Setup

20 |
21 | 22 |
23 |

24 | TTV LOL PRO is a Twitch ad blocker, but it can also be used to unlock 25 | the highest stream quality in regions where Twitch blocks it (e.g. 26 | Russia and South Korea). 27 |

28 |
    29 |
  • 30 | If you are in a region where Twitch blocks the highest stream 31 | quality, select the "Unlock Best Quality" option below. 32 |
    33 | 34 | 1440p and 4K resolutions may not be unlocked depending on the 35 | proxy you are using and/or your device. 36 | 37 |

    38 |
  • 39 |
  • 40 | Otherwise, choose the "Block Ads" option (default) to block ads on 41 | Twitch. 42 |
  • 43 |
44 | 45 |
46 |
47 | 53 | 54 |
55 |
56 | 62 | 63 |
64 | 73 |
74 | 75 | 76 |
77 | 78 | 79 | 82 |
83 | 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /src/common/ts/proxyInfo.ts: -------------------------------------------------------------------------------- 1 | import { Address6 } from "ip-address"; 2 | import type { ProxyInfo, ProxyType } from "../../types"; 3 | 4 | const DEFAULT_PORTS: Readonly> = Object.freeze({ 5 | direct: 0, 6 | http: 80, 7 | https: 443, 8 | socks: 1080, 9 | socks4: 1080, 10 | }); 11 | 12 | export function getProxyInfoFromUrl( 13 | url: string 14 | ): ProxyInfo & { host: string; port: number } { 15 | let type: ProxyType | undefined = undefined; 16 | if (url.includes("://")) { 17 | const [protocol] = url.split("://", 1); 18 | type = protocol as ProxyType; 19 | url = url.substring(protocol.length + 3, url.length); 20 | } 21 | 22 | const lastIndexOfAt = url.lastIndexOf("@"); 23 | const hostname = url.substring(lastIndexOfAt + 1, url.length); 24 | const lastIndexOfColon = getLastIndexOfColon(hostname); 25 | 26 | let host: string | undefined = undefined; 27 | let port: number | undefined = undefined; 28 | if (lastIndexOfColon === -1) { 29 | host = hostname; 30 | port = type ? DEFAULT_PORTS[type] : 3128; // Default port 31 | } else { 32 | host = hostname.substring(0, lastIndexOfColon); 33 | port = Number(hostname.substring(lastIndexOfColon + 1, hostname.length)); 34 | } 35 | if (host.startsWith("[") && host.endsWith("]")) { 36 | host = host.substring(1, host.length - 1); 37 | } 38 | 39 | let username: string | undefined = undefined; 40 | let password: string | undefined = undefined; 41 | if (lastIndexOfAt !== -1) { 42 | const credentials = url.substring(0, lastIndexOfAt); 43 | const indexOfColon = credentials.indexOf(":"); 44 | username = credentials.substring(0, indexOfColon); 45 | password = credentials.substring(indexOfColon + 1, credentials.length); 46 | } 47 | 48 | return { 49 | type: type ?? "http", 50 | host, 51 | port, 52 | username, 53 | password, 54 | }; 55 | } 56 | 57 | /** 58 | * Returns the last index of a colon in a hostname, ignoring colons inside brackets. 59 | * Supports IPv6 addresses. 60 | * @param hostname 61 | * @returns Returns -1 if no colon is found. 62 | */ 63 | function getLastIndexOfColon(hostname: string): number { 64 | let lastIndexOfColon = -1; 65 | let bracketDepth = 0; 66 | for (let i = hostname.length - 1; i >= 0; i--) { 67 | const char = hostname[i]; 68 | if (char === "]") { 69 | bracketDepth++; 70 | } else if (char === "[") { 71 | bracketDepth--; 72 | } else if (char === ":" && bracketDepth === 0) { 73 | lastIndexOfColon = i; 74 | break; 75 | } 76 | } 77 | return lastIndexOfColon; 78 | } 79 | 80 | export function getUrlFromProxyInfo(proxyInfo: ProxyInfo): string { 81 | const { host, port, username, password } = proxyInfo; 82 | if (!host) return ""; 83 | let url = ""; 84 | if (username && password) { 85 | url = `${username}:${password}@`; 86 | } else if (username) { 87 | url = `${username}@`; 88 | } 89 | const isIPv6 = Address6.isValid(host); 90 | if (isIPv6) { 91 | url += `[${host}]`; 92 | } else { 93 | url += host; 94 | } 95 | if (port) { 96 | url += `:${port}`; 97 | } 98 | return url; 99 | } 100 | -------------------------------------------------------------------------------- /src/background/background.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | import isChromium from "../common/ts/isChromium"; 3 | import checkForOpenedTwitchTabs from "./handlers/checkForOpenedTwitchTabs"; 4 | import onAuthRequired from "./handlers/onAuthRequired"; 5 | import onBeforeSendHeaders from "./handlers/onBeforeSendHeaders"; 6 | import onContentScriptMessage from "./handlers/onContentScriptMessage"; 7 | import onInstalled from "./handlers/onInstalled"; 8 | import onProxyRequest from "./handlers/onProxyRequest"; 9 | import onResponseStarted from "./handlers/onResponseStarted"; 10 | import onStartupStoreCleanup from "./handlers/onStartupStoreCleanup"; 11 | import onTabCreated from "./handlers/onTabCreated"; 12 | import onTabRemoved from "./handlers/onTabRemoved"; 13 | import onTabReplaced from "./handlers/onTabReplaced"; 14 | import onTabUpdated from "./handlers/onTabUpdated"; 15 | 16 | console.info("🚀 Background script loaded."); 17 | 18 | // Cleanup old data in the store on update & open setup on install/update. 19 | browser.runtime.onInstalled.addListener(onInstalled); 20 | 21 | // Cleanup session data in the store on startup. 22 | browser.runtime.onStartup.addListener(onStartupStoreCleanup); 23 | 24 | // Listen to messages from the content script. 25 | browser.runtime.onMessage.addListener(onContentScriptMessage); 26 | 27 | // Handle proxy authentication. 28 | browser.webRequest.onAuthRequired.addListener( 29 | onAuthRequired, 30 | { 31 | urls: [ 32 | "https://*.live-video.net/*", 33 | "https://*.ttvnw.net/*", 34 | "https://*.twitch.tv/*", 35 | ], 36 | }, 37 | ["blocking"] 38 | ); 39 | 40 | // Monitor proxied status of requests. 41 | browser.webRequest.onResponseStarted.addListener(onResponseStarted, { 42 | urls: [ 43 | "https://*.live-video.net/*", 44 | "https://*.ttvnw.net/*", 45 | "https://*.twitch.tv/*", 46 | ], 47 | }); 48 | 49 | if (isChromium) { 50 | // Check if there are any opened Twitch tabs on startup. 51 | checkForOpenedTwitchTabs(); 52 | 53 | // Keep track of opened Twitch tabs to enable/disable the PAC script. 54 | browser.tabs.onCreated.addListener(onTabCreated); 55 | browser.tabs.onUpdated.addListener(onTabUpdated); 56 | browser.tabs.onRemoved.addListener(onTabRemoved); 57 | browser.tabs.onReplaced.addListener(onTabReplaced); 58 | } else { 59 | // Block tracking pixels. 60 | browser.webRequest.onBeforeRequest.addListener( 61 | () => ({ cancel: true }), 62 | { 63 | urls: ["https://*.twitch.tv/r/s/*", "https://*.twitch.tv/r/c/*"], 64 | }, 65 | ["blocking"] 66 | ); 67 | 68 | // Proxy requests. 69 | browser.proxy.onRequest.addListener( 70 | onProxyRequest, 71 | { 72 | urls: [ 73 | "https://*.live-video.net/*", 74 | "https://*.ttvnw.net/*", 75 | "https://*.twitch.tv/*", 76 | ], 77 | }, 78 | ["requestHeaders"] 79 | ); 80 | 81 | // Remove the Accept flag from flagged requests. 82 | browser.webRequest.onBeforeSendHeaders.addListener( 83 | onBeforeSendHeaders, 84 | { 85 | urls: [ 86 | "https://*.live-video.net/*", 87 | "https://*.ttvnw.net/*", 88 | "https://*.twitch.tv/*", 89 | ], 90 | }, 91 | ["blocking", "requestHeaders"] 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/common/ts/isRequestTypeProxied.ts: -------------------------------------------------------------------------------- 1 | import { ProxyRequestParams, ProxyRequestType } from "../../types"; 2 | 3 | export default function isRequestTypeProxied( 4 | type: ProxyRequestType, 5 | params: ProxyRequestParams 6 | ): boolean { 7 | if (type === ProxyRequestType.Passport) { 8 | return params.customPassport?.passport ?? params.passportLevel >= 1; 9 | } 10 | 11 | if (type === ProxyRequestType.Usher) { 12 | if (params.optimizedProxiesEnabled) { 13 | if (params.isChromium && params.fullModeEnabled === false) { 14 | return false; 15 | } 16 | if (!params.isChromium && params.isFlagged === false) { 17 | return false; 18 | } 19 | } 20 | return params.customPassport?.usher ?? params.passportLevel >= 0; 21 | } 22 | 23 | if (type === ProxyRequestType.VideoWeaver) { 24 | if (params.optimizedProxiesEnabled) { 25 | if (params.isChromium && params.fullModeEnabled === false) { 26 | return false; 27 | } 28 | if (!params.isChromium && params.isFlagged === false) { 29 | return false; 30 | } 31 | } 32 | return params.customPassport?.videoWeaver ?? params.passportLevel >= 0; 33 | } 34 | 35 | if (type === ProxyRequestType.GraphQLToken) { 36 | if (params.isChromium) { 37 | return params.customPassport?.graphQLToken ?? params.passportLevel >= 1; 38 | } else { 39 | return params.customPassport?.graphQLToken ?? params.passportLevel >= 0; 40 | } 41 | } 42 | 43 | if (type === ProxyRequestType.GraphQLIntegrity) { 44 | if (params.optimizedProxiesEnabled) { 45 | return ( 46 | params.customPassport?.graphQLIntegrity ?? params.passportLevel >= 2 47 | ); 48 | } else { 49 | return ( 50 | params.customPassport?.graphQLIntegrity ?? params.passportLevel >= 1 51 | ); 52 | } 53 | } 54 | 55 | if (type === ProxyRequestType.GraphQLAll) { 56 | if (!params.optimizedProxiesEnabled) { 57 | const customPassportSomeGraphQL = 58 | params.customPassport?.graphQLToken || 59 | params.customPassport?.graphQLIntegrity || 60 | params.customPassport?.graphQLAll; 61 | if ( 62 | params.isChromium && 63 | (customPassportSomeGraphQL ?? params.passportLevel >= 1) 64 | ) { 65 | return true; 66 | } 67 | if ( 68 | !params.isChromium && 69 | (params.customPassport?.graphQLAll ?? params.passportLevel >= 2) 70 | ) { 71 | return true; 72 | } 73 | } 74 | return false; 75 | } 76 | 77 | if (type === ProxyRequestType.GraphQL) { 78 | if (isRequestTypeProxied(ProxyRequestType.GraphQLAll, params)) { 79 | return true; 80 | } 81 | if (params.isChromium && params.fullModeEnabled === false) { 82 | return false; 83 | } 84 | if (!params.isChromium && params.isFlagged === false) { 85 | return false; 86 | } 87 | return ( 88 | isRequestTypeProxied(ProxyRequestType.GraphQLToken, params) || 89 | isRequestTypeProxied(ProxyRequestType.GraphQLIntegrity, params) 90 | ); 91 | } 92 | 93 | if (type === ProxyRequestType.TwitchWebpage) { 94 | return params.customPassport?.twitchWebpage ?? params.passportLevel >= 2; 95 | } 96 | 97 | return false; 98 | } 99 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | import getDefaultState from "./getDefaultState"; 3 | import getStateHandler from "./handlers/getStateHandler"; 4 | import type { EventType, ReadyState, StorageAreaName } from "./types"; 5 | 6 | /** 7 | * A synchronous wrapper around the `browser.storage` API. 8 | */ 9 | class Store> { 10 | private readonly _areaName: StorageAreaName; 11 | private readonly _getDefaultState: () => T; 12 | private _state: T; // Raw state 13 | private _listenersByEvent: Record = { 14 | load: [], 15 | change: [], 16 | }; 17 | 18 | readyState: ReadyState = "loading"; 19 | state: T; // Proxy state 20 | 21 | constructor(areaName: StorageAreaName, getDefaultState: () => T) { 22 | this._areaName = areaName; 23 | this._getDefaultState = getDefaultState; 24 | // Temporary state to satisfy TypeScript until init() is called. 25 | this._state = this._getDefaultState(); 26 | this.state = this._state; 27 | this._init().then(() => { 28 | this.readyState = "complete"; 29 | this.dispatchEvent("load"); 30 | this._listenersByEvent["load"] = []; // Remove all load listeners. 31 | }); 32 | browser.storage.onChanged.addListener((changes, area) => { 33 | if (area !== this._areaName) return; 34 | for (const [key, { newValue }] of Object.entries(changes)) { 35 | if (newValue === undefined) continue; // Ignore deletions. 36 | this._state[key as keyof T] = newValue as T[keyof T]; 37 | } 38 | this.dispatchEvent("change", changes); 39 | }); 40 | } 41 | 42 | private async _init() { 43 | // Retrieve the entire storage contents. 44 | // From https://stackoverflow.com/questions/18150774/get-all-keys-from-chrome-storage 45 | const storage = await browser.storage[this._areaName].get(null); 46 | 47 | this._state = this._getDefaultState(); 48 | for (const [key, value] of Object.entries(storage)) { 49 | this._state[key as keyof T] = value as T[keyof T]; 50 | } 51 | const stateHandler = getStateHandler(this._areaName, this._state); 52 | const stateProxy = new Proxy(this._state, stateHandler); 53 | this.state = stateProxy; 54 | } 55 | 56 | async clear() { 57 | const defaultState = this._getDefaultState(); 58 | for (const [key, value] of Object.entries(defaultState)) { 59 | this.state[key as keyof T] = value; 60 | } 61 | await browser.storage[this._areaName].clear(); 62 | } 63 | 64 | addEventListener(type: EventType, listener: Function) { 65 | if (!this._listenersByEvent[type]) this._listenersByEvent[type] = []; 66 | this._listenersByEvent[type].push(listener); 67 | } 68 | 69 | removeEventListener(type: EventType, listener: Function) { 70 | if (!this._listenersByEvent[type]) return; 71 | const index = this._listenersByEvent[type].findIndex(x => x === listener); 72 | if (index !== -1) this._listenersByEvent[type].splice(index, 1); 73 | } 74 | 75 | dispatchEvent(type: EventType, ...args: any[]) { 76 | const listeners = this._listenersByEvent[type] || []; 77 | listeners.forEach(listener => listener(...args)); 78 | } 79 | } 80 | 81 | const store = new Store("local", getDefaultState); 82 | 83 | export default store; 84 | -------------------------------------------------------------------------------- /src/common/ts/ipAddress.ts: -------------------------------------------------------------------------------- 1 | import { Address4, Address6 } from "ip-address"; 2 | import { getProxyInfoFromUrl } from "./proxyInfo"; 3 | 4 | const ip4LinkLocalSubnet = new Address4("169.254.0.0/16"); 5 | const ip4LoopbackSubnet = new Address4("127.0.0.0/8"); 6 | const ip4PrivateASubnet = new Address4("10.0.0.0/8"); 7 | const ip4PrivateBSubnet = new Address4("172.16.0.0/12"); 8 | const ip4PrivateCSubnet = new Address4("192.168.0.0/16"); 9 | 10 | /** 11 | * Check if an IP address is private (link-local, loopback, or private range). 12 | * @param ip 13 | * @returns 14 | */ 15 | export function isPrivateIpAddress(ip: string): boolean { 16 | try { 17 | const ip4 = new Address4(ip); 18 | return ( 19 | ip4.isInSubnet(ip4LinkLocalSubnet) || 20 | ip4.isInSubnet(ip4LoopbackSubnet) || 21 | ip4.isInSubnet(ip4PrivateASubnet) || 22 | ip4.isInSubnet(ip4PrivateBSubnet) || 23 | ip4.isInSubnet(ip4PrivateCSubnet) 24 | ); 25 | } catch (error) {} 26 | 27 | try { 28 | const ip6 = new Address6(ip); 29 | return ip6.isLinkLocal() || ip6.isLoopback(); 30 | } catch (error) {} 31 | 32 | return false; 33 | } 34 | 35 | /** 36 | * Normalize an IP address to its canonical form. 37 | * @param ip 38 | * @returns 39 | */ 40 | export function normalizeIpAddress(ip: string): string | null { 41 | try { 42 | if (Address4.isValid(ip)) { 43 | return new Address4(ip).correctForm(); 44 | } 45 | if (Address6.isValid(ip)) { 46 | const addr6 = new Address6(ip); 47 | // Handle IPv4-mapped IPv6 addresses (e.g. ::ffff:192.0.2.128). 48 | if (addr6.is4()) { 49 | return addr6.to4().correctForm(); 50 | } 51 | return addr6.correctForm(); 52 | } 53 | } catch { 54 | return null; 55 | } 56 | return null; 57 | } 58 | 59 | /** 60 | * Anonymize an IP address by masking the last 2 octets of an IPv4 address 61 | * or the last 8 octets of an IPv6 address. 62 | * @param url 63 | * @returns 64 | */ 65 | export function anonymizeIpAddress(url: string): string { 66 | const proxyInfo = getProxyInfoFromUrl(url); 67 | 68 | let proxyHost = proxyInfo.host; 69 | 70 | const isIPv4 = Address4.isValid(proxyHost); 71 | const isIPv6 = Address6.isValid(proxyHost); 72 | const isIP = isIPv4 || isIPv6; 73 | const isPublicIP = isIP && !isPrivateIpAddress(proxyHost); 74 | 75 | if (isPublicIP) { 76 | if (isIPv4) { 77 | proxyHost = new Address4(proxyHost) 78 | .correctForm() 79 | .split(".") 80 | .map((byte, index) => (index < 2 ? byte : "xxx")) 81 | .join("."); 82 | } else if (isIPv6) { 83 | const bytes = new Address6(proxyHost).toByteArray(); 84 | const anonymizedBytes = bytes.map((byte, index) => 85 | index < 6 ? byte : 0x0 86 | ); 87 | proxyHost = Address6.fromByteArray(anonymizedBytes) 88 | .correctForm() 89 | .replace(/::$/, "::xxxx"); 90 | } 91 | } 92 | 93 | return proxyHost; // Anonymize port by removing it. 94 | } 95 | 96 | /** 97 | * Anonymize an array of IP addresses. See {@link anonymizeIpAddress}. 98 | * @param urls 99 | * @returns 100 | */ 101 | export function anonymizeIpAddresses(urls: string[]): string[] { 102 | return urls.map(url => anonymizeIpAddress(url)); 103 | } 104 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: ["v2"] 17 | tags-ignore: ["**"] 18 | pull_request: 19 | types: [opened, synchronize, reopened, ready_for_review] 20 | branches: ["v2"] 21 | schedule: 22 | - cron: "32 9 * * 6" 23 | 24 | concurrency: 25 | group: ${{ github.workflow }}-${{ github.ref }} 26 | cancel-in-progress: true 27 | 28 | jobs: 29 | analyze: 30 | name: Analyze 31 | runs-on: ubuntu-latest 32 | if: ${{ !github.event.pull_request.draft }} 33 | permissions: 34 | actions: read 35 | contents: read 36 | security-events: write 37 | 38 | strategy: 39 | fail-fast: false 40 | matrix: 41 | language: ["javascript"] 42 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 43 | # Use only 'java' to analyze code written in Java, Kotlin or both 44 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 45 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 46 | 47 | steps: 48 | - name: Checkout repository 49 | uses: actions/checkout@v4 50 | 51 | # Initializes the CodeQL tools for scanning. 52 | - name: Initialize CodeQL 53 | uses: github/codeql-action/init@v3 54 | with: 55 | languages: ${{ matrix.language }} 56 | # If you wish to specify custom queries, you can do so here or in a config file. 57 | # By default, queries listed here will override any specified in a config file. 58 | # Prefix the list here with "+" to use these queries and those in the config file. 59 | 60 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 61 | # queries: security-extended,security-and-quality 62 | 63 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 64 | # If this step fails, then you should remove it and run the build manually (see below) 65 | - name: Autobuild 66 | uses: github/codeql-action/autobuild@v3 67 | 68 | # ℹ️ Command-line programs to run using the OS shell. 69 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 70 | 71 | # If the Autobuild fails above, remove it and uncomment the following three lines. 72 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 73 | 74 | # - run: | 75 | # echo "Run, Build Application using script" 76 | # ./location_of_script_within_repo/buildscript.sh 77 | 78 | - name: Perform CodeQL Analysis 79 | uses: github/codeql-action/analyze@v3 80 | with: 81 | category: "/language:${{matrix.language}}" 82 | -------------------------------------------------------------------------------- /src/page/worker.ts: -------------------------------------------------------------------------------- 1 | import { Mutex } from "async-mutex"; 2 | import Logger from "../common/ts/Logger"; 3 | import { MessageType, ProxyRequestType } from "../types"; 4 | import getFetch from "./getFetch"; 5 | import { 6 | getSendMessageToContentScript, 7 | getSendMessageToContentScriptAndWaitForResponse, 8 | getSendMessageToPageScript, 9 | getSendMessageToPageScriptAndWaitForResponse, 10 | getSendMessageToWorkerScripts, 11 | getSendMessageToWorkerScriptsAndWaitForResponse, 12 | } from "./sendMessage"; 13 | import type { PageState } from "./types"; 14 | 15 | const logger = new Logger("Worker"); 16 | logger.log("Worker script running."); 17 | 18 | declare var getParams: () => string; 19 | let params; 20 | try { 21 | params = JSON.parse(getParams()!); 22 | } catch (error) { 23 | logger.error("Failed to parse params:", error); 24 | } 25 | getParams = undefined as any; 26 | 27 | const broadcastChannel = new BroadcastChannel(params.broadcastChannelName); 28 | const sendMessageToContentScript = 29 | getSendMessageToContentScript(broadcastChannel); 30 | const sendMessageToContentScriptAndWaitForResponse = 31 | getSendMessageToContentScriptAndWaitForResponse(broadcastChannel); 32 | const sendMessageToPageScript = getSendMessageToPageScript(broadcastChannel); 33 | const sendMessageToPageScriptAndWaitForResponse = 34 | getSendMessageToPageScriptAndWaitForResponse(broadcastChannel); 35 | const sendMessageToWorkerScripts = 36 | getSendMessageToWorkerScripts(broadcastChannel); 37 | const sendMessageToWorkerScriptsAndWaitForResponse = 38 | getSendMessageToWorkerScriptsAndWaitForResponse(broadcastChannel); 39 | 40 | const pageState: PageState = { 41 | params: params, 42 | isChromium: params.isChromium, 43 | scope: "worker", 44 | state: undefined, 45 | requestTypeMutexes: { 46 | [ProxyRequestType.Passport]: new Mutex(), 47 | [ProxyRequestType.Usher]: new Mutex(), 48 | [ProxyRequestType.VideoWeaver]: new Mutex(), 49 | [ProxyRequestType.GraphQL]: new Mutex(), 50 | [ProxyRequestType.GraphQLToken]: new Mutex(), 51 | [ProxyRequestType.GraphQLIntegrity]: new Mutex(), 52 | [ProxyRequestType.GraphQLAll]: new Mutex(), 53 | [ProxyRequestType.TwitchWebpage]: new Mutex(), 54 | }, 55 | twitchWorkers: [], // Always empty in workers. 56 | sendMessageToContentScript, 57 | sendMessageToContentScriptAndWaitForResponse, 58 | sendMessageToPageScript, 59 | sendMessageToPageScriptAndWaitForResponse, 60 | sendMessageToWorkerScripts, 61 | sendMessageToWorkerScriptsAndWaitForResponse, 62 | }; 63 | 64 | const newFetch = getFetch(pageState); 65 | self.fetch = newFetch; 66 | if (self.fetch !== newFetch) { 67 | logger.error("Failed to replace fetch."); 68 | sendMessageToContentScript({ 69 | type: MessageType.ExtensionError, 70 | errorMessage: 71 | "Failed to replace fetch. Are you using another Twitch extension?", 72 | }); 73 | } else { 74 | logger.log("fetch replaced successfully."); 75 | } 76 | 77 | broadcastChannel.addEventListener("message", event => { 78 | if (!event.data || event.data.type !== MessageType.WorkerScriptMessage) { 79 | return; 80 | } 81 | 82 | const { message } = event.data; 83 | if (!message) return; 84 | 85 | switch (message.type) { 86 | case MessageType.GetStoreStateResponse: 87 | if (pageState.state == null) { 88 | logger.log("Received store state from content script."); 89 | } else { 90 | logger.debug("Received store state from content script."); 91 | } 92 | const state = message.state; 93 | pageState.state = state; 94 | break; 95 | } 96 | }); 97 | sendMessageToContentScript({ 98 | type: MessageType.GetStoreState, 99 | from: pageState.scope, 100 | }); 101 | -------------------------------------------------------------------------------- /src/common/ts/updateDnsResponses.ts: -------------------------------------------------------------------------------- 1 | import { Address4, Address6 } from "ip-address"; 2 | import store from "../../store"; 3 | import type { DnsResponse, DnsResponseJson } from "../../types"; 4 | import { getProxyInfoFromUrl } from "./proxyInfo"; 5 | 6 | const DNS_API = "https://cloudflare-dns.com/dns-query"; 7 | const MIN_TTL = 300; 8 | const INFINITE_TTL = -1; 9 | 10 | export default async function updateDnsResponses() { 11 | const proxies = Array.from( 12 | new Set([...store.state.optimizedProxies, ...store.state.normalProxies]) 13 | ); 14 | const proxyInfoArray = proxies.map(getProxyInfoFromUrl); 15 | 16 | for (const proxyInfo of proxyInfoArray) { 17 | const { host } = proxyInfo; 18 | 19 | // If we already have a valid DNS response for this host, skip it. 20 | const existingIndex = store.state.dnsResponses.findIndex( 21 | dnsResponse => dnsResponse.host === host 22 | ); 23 | const existing = 24 | existingIndex !== -1 ? store.state.dnsResponses[existingIndex] : null; 25 | const isDnsResponseValid = 26 | existing && 27 | (existing.ttl === INFINITE_TTL || 28 | Date.now() - existing.timestamp < existing.ttl * 1000); 29 | if (isDnsResponseValid) { 30 | continue; 31 | } 32 | 33 | // If the host is an IP address, use it directly. 34 | const isIp = Address4.isValid(host) || Address6.isValid(host); 35 | if (isIp) { 36 | upsertDnsResponse(existingIndex, { 37 | host, 38 | ips: [host], 39 | timestamp: Date.now(), 40 | ttl: INFINITE_TTL, 41 | }); 42 | continue; 43 | } 44 | 45 | // Otherwise, fetch DNS records from the DNS-over-HTTPS API. 46 | try { 47 | const requests = [ 48 | fetch(`${DNS_API}?name=${encodeURIComponent(host)}&type=A`, { 49 | headers: { Accept: "application/dns-json" }, 50 | }), 51 | fetch(`${DNS_API}?name=${encodeURIComponent(host)}&type=AAAA`, { 52 | headers: { Accept: "application/dns-json" }, 53 | }), 54 | ]; 55 | const responses = await Promise.all(requests); 56 | 57 | let ips: string[] = []; 58 | let ttl: number | null = null; 59 | for (const response of responses) { 60 | if (!response.ok) { 61 | console.error( 62 | `Failed to fetch DNS for ${host}: HTTP ${response.status}` 63 | ); 64 | continue; 65 | } 66 | const data: DnsResponseJson = await response.json(); 67 | if (data.Status !== 0) { 68 | console.error(`DNS query for ${host} returned status ${data.Status}`); 69 | continue; 70 | } 71 | const { Answer } = data; 72 | if (Answer) { 73 | ips.push(...Answer.map(answer => answer.data)); 74 | const answerTtl = Math.min(...Answer.map(answer => answer.TTL)); 75 | if (ttl == null || answerTtl < ttl) { 76 | ttl = answerTtl; 77 | } 78 | } 79 | } 80 | if (ips.length === 0) { 81 | console.error(`No DNS answers found for ${host}`); 82 | continue; 83 | } 84 | 85 | upsertDnsResponse(existingIndex, { 86 | host, 87 | ips: Array.from(new Set(ips)), // Remove duplicates 88 | timestamp: Date.now(), 89 | ttl: ttl ? Math.max(ttl, MIN_TTL) : MIN_TTL, // Enforce minimum TTL 90 | }); 91 | } catch (error) { 92 | console.error(error); 93 | } 94 | } 95 | 96 | console.log("🔍 DNS responses updated:"); 97 | console.log(store.state.dnsResponses); 98 | } 99 | 100 | /** 101 | * Upsert a DNS response into the store. 102 | * @param existingIndex Index of existing DNS response, or -1 if not found. 103 | * @param dnsResponse The DNS response to upsert. 104 | */ 105 | function upsertDnsResponse(existingIndex: number, dnsResponse: DnsResponse) { 106 | if (existingIndex !== -1) { 107 | store.state.dnsResponses.splice(existingIndex, 1, dnsResponse); 108 | } else { 109 | store.state.dnsResponses.push(dnsResponse); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/background/handlers/onContentScriptMessage.ts: -------------------------------------------------------------------------------- 1 | import browser, { Runtime } from "webextension-polyfill"; 2 | import isChromium from "../../common/ts/isChromium"; 3 | import { updateProxySettings } from "../../common/ts/proxySettings"; 4 | import { getStreamStatus, setStreamStatus } from "../../common/ts/streamStatus"; 5 | import store from "../../store"; 6 | import { MessageType, ProxyRequestType } from "../../types"; 7 | 8 | type Timeout = string | number | NodeJS.Timeout | undefined; 9 | 10 | const timeoutMap: Map = new Map(); 11 | 12 | export default function onContentScriptMessage( 13 | message: any, 14 | sender: Runtime.MessageSender, 15 | sendResponse?: (message: any) => void 16 | ): Promise | true | undefined { 17 | if (message.type === MessageType.EnableFullMode) { 18 | const tabId = sender.tab?.id; 19 | if (!tabId) return; 20 | 21 | const requestType = message.requestType as ProxyRequestType; 22 | 23 | // Clear existing timeout for request type. 24 | if (timeoutMap.has(requestType)) { 25 | clearTimeout(timeoutMap.get(requestType)); 26 | } 27 | 28 | // Set new timeout for request type. 29 | const timeoutMs = 10000; 30 | timeoutMap.set( 31 | requestType, 32 | setTimeout(() => { 33 | timeoutMap.delete(requestType); 34 | if (isChromium && store.state.chromiumProxyActive) { 35 | updateProxySettings([...timeoutMap.keys()]); 36 | } 37 | console.log( 38 | `🔴 Disabled full mode (request type: ${requestType}, timeout: ${timeoutMs}ms)` 39 | ); 40 | try { 41 | browser.tabs.sendMessage(tabId, { 42 | type: MessageType.DisableFullModeResponse, 43 | requestType, 44 | reason: "TIMEOUT", 45 | }); 46 | } catch (error) { 47 | console.error( 48 | "❌ Failed to send DisableFullModeResponse message", 49 | error 50 | ); 51 | } 52 | }, timeoutMs) 53 | ); 54 | if (isChromium && store.state.chromiumProxyActive) { 55 | updateProxySettings([...timeoutMap.keys()]); 56 | } 57 | 58 | console.log( 59 | `🟢 Enabled full mode for ${timeoutMs}ms (request type: ${requestType})` 60 | ); 61 | try { 62 | browser.tabs.sendMessage(tabId, { 63 | type: MessageType.EnableFullModeResponse, 64 | requestType, 65 | reason: "ENABLED", 66 | }); 67 | } catch (error) { 68 | console.error("❌ Failed to send EnableFullModeResponse message", error); 69 | } 70 | } 71 | 72 | if (message.type === MessageType.DisableFullMode) { 73 | const tabId = sender.tab?.id; 74 | if (!tabId) return; 75 | 76 | const requestType = message.requestType as ProxyRequestType; 77 | 78 | // Clear existing timeout for request type. 79 | if (timeoutMap.has(requestType)) { 80 | clearTimeout(timeoutMap.get(requestType)); 81 | timeoutMap.delete(requestType); 82 | } 83 | if (isChromium && store.state.chromiumProxyActive) { 84 | updateProxySettings([...timeoutMap.keys()]); 85 | } 86 | 87 | console.log(`🔴 Disabled full mode (request type: ${requestType})`); 88 | try { 89 | browser.tabs.sendMessage(tabId, { 90 | type: MessageType.DisableFullModeResponse, 91 | requestType, 92 | reason: "DISABLED", 93 | }); 94 | } catch (error) { 95 | console.error("❌ Failed to send DisableFullModeResponse message", error); 96 | } 97 | } 98 | 99 | if (message.type === MessageType.UsherResponse) { 100 | const { channel, videoWeaverUrls, proxyCountry } = message; 101 | // Update Video Weaver URLs. 102 | store.state.videoWeaverUrlsByChannel[channel] = [ 103 | ...(store.state.videoWeaverUrlsByChannel[channel] ?? []), 104 | ...videoWeaverUrls, 105 | ]; 106 | // Update proxy country. 107 | const streamStatus = getStreamStatus(channel); 108 | setStreamStatus(channel, { 109 | ...(streamStatus ?? { proxied: false, reason: "" }), 110 | proxyCountry, 111 | }); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // From https://stackoverflow.com/a/51419293 2 | export type KeyOfType = keyof { 3 | [P in keyof T as T[P] extends V ? P : never]: any; 4 | }; 5 | 6 | // From https://www.charpeni.com/blog/properly-type-object-keys-and-object-entries#solution-1 7 | export type ObjectEntries = Array<[keyof T, T[keyof T]]>; 8 | 9 | export type ProxyType = "direct" | "http" | "https" | "socks" | "socks4"; 10 | 11 | // From https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/proxy/ProxyInfo 12 | export interface ProxyInfo { 13 | type: ProxyType; 14 | host?: string; 15 | port?: number; 16 | username?: string; 17 | password?: string; 18 | proxyDNS?: boolean; 19 | failoverTimeout?: number; 20 | proxyAuthorizationHeader?: string; 21 | connectionIsolationKey?: string; 22 | } 23 | 24 | export interface AdLogEntry { 25 | timestamp: number; 26 | channelName: string | null; 27 | videoWeaverUrl: string; 28 | rawLine: string; 29 | parsedLine?: { 30 | adRollType: "PREROLL" | "MIDROLL"; 31 | adUrl: string; 32 | adUrlHighlight?: string; 33 | adClickTrackingUrl: string; 34 | adClickTrackingUrlHighlight?: string; 35 | adLineItemId: string; 36 | adCommercialId?: string; 37 | adDsaAdvertiserId?: string; 38 | adDsaCampaignId?: string; 39 | }; 40 | adIdentity?: { 41 | advertiserName: string; 42 | payerName: string; 43 | isIdentityVerified: boolean; 44 | } | null; 45 | } 46 | 47 | export interface StreamStatus { 48 | proxied: boolean; 49 | proxyHost?: string; 50 | proxyCountry?: string; 51 | reason: string; 52 | stats?: { 53 | proxied: number; 54 | notProxied: number; 55 | }; 56 | } 57 | 58 | export interface DnsResponse { 59 | host: string; 60 | ips: string[]; 61 | timestamp: number; 62 | ttl: number; 63 | } 64 | 65 | export interface DnsResponseJson { 66 | Status: number; 67 | TC: boolean; // Truncated 68 | RD: boolean; // Recursion Desired 69 | RA: boolean; // Recursion Available 70 | AD: boolean; // Authentic Data 71 | CD: boolean; // Checking Disabled 72 | Question: { 73 | name: string; 74 | type: number; 75 | }[]; 76 | Answer: { 77 | name: string; 78 | type: number; 79 | TTL: number; 80 | data: string; 81 | }[]; 82 | } 83 | 84 | export const enum MessageType { 85 | ContentScriptMessage = "TLP_ContentScriptMessage", 86 | PageScriptMessage = "TLP_PageScriptMessage", 87 | WorkerScriptMessage = "TLP_WorkerScriptMessage", 88 | GetStoreState = "TLP_GetStoreState", 89 | GetStoreStateResponse = "TLP_GetStoreStateResponse", 90 | EnableFullMode = "TLP_EnableFullMode", 91 | EnableFullModeResponse = "TLP_EnableFullModeResponse", 92 | DisableFullMode = "TLP_DisableFullMode", 93 | DisableFullModeResponse = "TLP_DisableFullModeResponse", 94 | UsherResponse = "TLP_UsherResponse", 95 | NewPlaybackAccessToken = "TLP_NewPlaybackAccessToken", 96 | NewPlaybackAccessTokenResponse = "TLP_NewPlaybackAccessTokenResponse", 97 | ChannelSubStatusChange = "TLP_ChannelSubStatusChange", 98 | UpdateAdLog = "TLP_UpdateAdLog", 99 | ClearStats = "TLP_ClearStats", 100 | ExtensionError = "TLP_ExtensionError", 101 | } 102 | 103 | export const enum ProxyRequestType { 104 | Passport = "passport", 105 | Usher = "usher", 106 | VideoWeaver = "videoWeaver", 107 | GraphQL = "graphQL", 108 | GraphQLToken = "graphQLToken", 109 | GraphQLIntegrity = "graphQLIntegrity", 110 | GraphQLAll = "graphQLAll", 111 | TwitchWebpage = "twitchWebpage", 112 | } 113 | 114 | export type ProxyRequestParams = 115 | | { 116 | isChromium: true; 117 | optimizedProxiesEnabled: boolean; 118 | passportLevel: number; 119 | customPassport: PassportConfig | null; 120 | fullModeEnabled?: boolean; 121 | } 122 | | { 123 | isChromium: false; 124 | optimizedProxiesEnabled: boolean; 125 | passportLevel: number; 126 | customPassport: PassportConfig | null; 127 | isFlagged?: boolean; 128 | }; 129 | 130 | export type PassportConfig = Record< 131 | Exclude, 132 | boolean 133 | >; 134 | 135 | export type UserExperienceMode = 136 | | "blockAds" 137 | | "unlockBestQuality" 138 | | "expertMode"; 139 | -------------------------------------------------------------------------------- /src/page/sendMessage.ts: -------------------------------------------------------------------------------- 1 | import { MessageType } from "../types"; 2 | import type { SendMessageAndWaitForResponseFn, SendMessageFn } from "./types"; 3 | 4 | function sendMessage( 5 | broadcastChannel: BroadcastChannel, 6 | type: MessageType, 7 | message: any 8 | ): void { 9 | broadcastChannel.postMessage({ 10 | type, 11 | message, 12 | }); 13 | } 14 | 15 | async function sendMessageAndWaitForResponse( 16 | broadcastChannel: BroadcastChannel, 17 | type: MessageType, 18 | message: any, 19 | responseType: MessageType, 20 | responseMessageType: MessageType, 21 | responseTimeout: number 22 | ): Promise { 23 | return new Promise((resolve, reject) => { 24 | const listener = (event: MessageEvent) => { 25 | if (!event.data || event.data.type !== responseType) return; 26 | const { message } = event.data; 27 | if (!message) return; 28 | if (message.type === responseMessageType) { 29 | broadcastChannel.removeEventListener("message", listener); 30 | resolve(message); 31 | } 32 | }; 33 | 34 | broadcastChannel.addEventListener("message", listener); 35 | broadcastChannel.postMessage({ 36 | type, 37 | message, 38 | responseType, 39 | responseMessageType, 40 | }); 41 | setTimeout(() => { 42 | broadcastChannel.removeEventListener("message", listener); 43 | reject( 44 | new Error( 45 | `Timed out after ${responseTimeout}ms waiting for message response (broadcast channel: ${broadcastChannel.name}).` 46 | ) 47 | ); 48 | }, responseTimeout); 49 | }); 50 | } 51 | 52 | export function getSendMessageToContentScript( 53 | broadcastChannel: BroadcastChannel 54 | ): SendMessageFn { 55 | return (message: any) => 56 | sendMessage(broadcastChannel, MessageType.ContentScriptMessage, message); 57 | } 58 | 59 | export function getSendMessageToContentScriptAndWaitForResponse( 60 | broadcastChannel: BroadcastChannel 61 | ): SendMessageAndWaitForResponseFn { 62 | return async ( 63 | scope: "page" | "worker", 64 | message: any, 65 | responseMessageType: MessageType, 66 | responseTimeout: number = 10000 67 | ) => { 68 | return sendMessageAndWaitForResponse( 69 | broadcastChannel, 70 | MessageType.ContentScriptMessage, 71 | message, 72 | scope === "page" 73 | ? MessageType.PageScriptMessage 74 | : MessageType.WorkerScriptMessage, 75 | responseMessageType, 76 | responseTimeout 77 | ); 78 | }; 79 | } 80 | 81 | export function getSendMessageToPageScript( 82 | broadcastChannel: BroadcastChannel 83 | ): SendMessageFn { 84 | return (message: any) => 85 | sendMessage(broadcastChannel, MessageType.PageScriptMessage, message); 86 | } 87 | 88 | export function getSendMessageToPageScriptAndWaitForResponse( 89 | broadcastChannel: BroadcastChannel 90 | ): SendMessageAndWaitForResponseFn { 91 | return async ( 92 | scope: "page" | "worker", 93 | message: any, 94 | responseMessageType: MessageType, 95 | responseTimeout: number = 10000 96 | ) => { 97 | return sendMessageAndWaitForResponse( 98 | broadcastChannel, 99 | MessageType.PageScriptMessage, 100 | message, 101 | scope === "page" 102 | ? MessageType.PageScriptMessage 103 | : MessageType.WorkerScriptMessage, 104 | responseMessageType, 105 | responseTimeout 106 | ); 107 | }; 108 | } 109 | 110 | export function getSendMessageToWorkerScripts( 111 | broadcastChannel: BroadcastChannel 112 | ): SendMessageFn { 113 | return (message: any) => 114 | sendMessage(broadcastChannel, MessageType.WorkerScriptMessage, message); 115 | } 116 | 117 | export function getSendMessageToWorkerScriptsAndWaitForResponse( 118 | broadcastChannel: BroadcastChannel 119 | ): SendMessageAndWaitForResponseFn { 120 | return async ( 121 | scope: "page" | "worker", 122 | message: any, 123 | responseMessageType: MessageType, 124 | responseTimeout: number = 10000 125 | ) => { 126 | return sendMessageAndWaitForResponse( 127 | broadcastChannel, 128 | MessageType.WorkerScriptMessage, 129 | message, 130 | scope === "page" 131 | ? MessageType.PageScriptMessage 132 | : MessageType.WorkerScriptMessage, 133 | responseMessageType, 134 | responseTimeout 135 | ); 136 | }; 137 | } 138 | -------------------------------------------------------------------------------- /src/page/getWorker.ts: -------------------------------------------------------------------------------- 1 | import Logger from "../common/ts/Logger"; 2 | import toAbsoluteUrl from "../common/ts/toAbsoluteUrl"; 3 | import { MessageType } from "../types"; 4 | import type { PageState } from "./types"; 5 | 6 | const logger = new Logger("getWorker"); 7 | 8 | export default function getWorker(pageState: PageState): typeof Worker | null { 9 | // Check for other Twitch ad blockers at injection time. 10 | if (isUsingAnotherAdBlocker(window.Worker.prototype)) { 11 | logger.error("Another Twitch ad blocker is in use."); 12 | pageState.sendMessageToContentScript({ 13 | type: MessageType.ExtensionError, 14 | errorMessage: "Another Twitch ad blocker is in use", 15 | }); 16 | return null; // Do not replace Worker to avoid disabling the other ad blocker. 17 | } 18 | 19 | return class extends window.Worker { 20 | constructor(scriptURL: string | URL, options?: WorkerOptions) { 21 | const fullUrl = toAbsoluteUrl(scriptURL.toString()); 22 | const isTwitchWorker = fullUrl.includes(".twitch.tv"); 23 | if (!isTwitchWorker) { 24 | super(scriptURL, options); 25 | return; 26 | } 27 | // Check for other Twitch ad blockers at instantiation time (in case one was 28 | // injected after TTV LOL PRO). 29 | if (isUsingAnotherAdBlocker(window.Worker.prototype)) { 30 | logger.error("Another Twitch ad blocker is in use."); 31 | pageState.sendMessageToContentScript({ 32 | type: MessageType.ExtensionError, 33 | errorMessage: "Another Twitch ad blocker is in use", 34 | }); 35 | super(scriptURL, options); 36 | return; 37 | } 38 | let script = ""; 39 | // Fetch Twitch's script, since Firefox Nightly errors out when trying to 40 | // import a blob URL directly. 41 | const xhr = new XMLHttpRequest(); 42 | xhr.open("GET", fullUrl, false); 43 | xhr.send(); 44 | if (200 <= xhr.status && xhr.status < 300) { 45 | script = xhr.responseText; 46 | } else { 47 | logger.warn(`Failed to fetch script: ${xhr.statusText}`); 48 | script = `importScripts('${fullUrl}');`; // Will fail on Firefox Nightly. 49 | } 50 | // --------------------------------------- 51 | // 🦊 Attention Firefox Addon Reviewer 🦊 52 | // --------------------------------------- 53 | // Please note that this does NOT involve remote code execution. 54 | // The injected script is bundled with the extension. 55 | // Additionally, there is no custom Content Security Policy (CSP) in use. 56 | const newScript = ` 57 | var getParams = () => '${JSON.stringify(pageState.params)}'; 58 | try { 59 | importScripts('${pageState.params.workerScriptURL}'); 60 | } catch (error) { 61 | console.error('[TTV LOL PRO] (getWorker) Failed to load script: ${ 62 | pageState.params.workerScriptURL 63 | }:', error); 64 | } 65 | ${script} 66 | `; 67 | const newScriptURL = URL.createObjectURL( 68 | new Blob([newScript], { type: "text/javascript" }) 69 | ); 70 | // Required for VAFT (<9.0.0) compatibility. 71 | const wrapperScript = ` 72 | try { 73 | importScripts('${newScriptURL}'); 74 | } catch (error) { 75 | console.warn('[TTV LOL PRO] (getWorker) Failed to wrap script: ${newScriptURL}:', error); 76 | ${newScript} 77 | } 78 | `; 79 | const wrapperScriptURL = URL.createObjectURL( 80 | new Blob([wrapperScript], { type: "text/javascript" }) 81 | ); 82 | super(wrapperScriptURL, options); 83 | pageState.twitchWorkers.push(this); 84 | // Can't revoke `newScriptURL` because of a conflict with VAFT. 85 | URL.revokeObjectURL(wrapperScriptURL); 86 | } 87 | }; 88 | } 89 | 90 | /** 91 | * Check if the worker's prototype chain contains known ad blocker code. 92 | * @param worker 93 | * @returns Whether another ad blocker is in use. 94 | */ 95 | function isUsingAnotherAdBlocker(worker: Worker): boolean { 96 | let proto = worker; 97 | while (proto) { 98 | const workerString = proto.toString(); 99 | if ( 100 | workerString.includes("twitch") && 101 | (workerString.includes("getAdBlockDiv") || 102 | workerString.includes("getAdDiv")) 103 | ) { 104 | return true; 105 | } 106 | proto = Object.getPrototypeOf(proto); 107 | } 108 | return false; 109 | } 110 | -------------------------------------------------------------------------------- /src/common/ts/proxySettings.ts: -------------------------------------------------------------------------------- 1 | import store from "../../store"; 2 | import { ProxyRequestType, ProxyType } from "../../types"; 3 | import isRequestTypeProxied from "./isRequestTypeProxied"; 4 | import { getProxyInfoFromUrl, getUrlFromProxyInfo } from "./proxyInfo"; 5 | import { 6 | passportHostRegex, 7 | twitchGqlHostRegex, 8 | twitchTvHostRegex, 9 | usherHostRegex, 10 | videoWeaverHostRegex, 11 | } from "./regexes"; 12 | import updateDnsResponses from "./updateDnsResponses"; 13 | 14 | const PROXY_TYPE_MAP: Readonly> = Object.freeze({ 15 | direct: "DIRECT", 16 | http: "PROXY", 17 | https: "HTTPS", 18 | socks: "SOCKS5", 19 | socks4: "SOCKS4", 20 | }); 21 | 22 | export function updateProxySettings(requestFilter?: ProxyRequestType[]) { 23 | const { optimizedProxiesEnabled, passportLevel } = store.state; 24 | 25 | const proxies = optimizedProxiesEnabled 26 | ? store.state.optimizedProxies 27 | : store.state.normalProxies; 28 | const proxyInfoString = getProxyInfoStringFromUrls(proxies); 29 | 30 | const getRequestParams = (requestType: ProxyRequestType) => ({ 31 | isChromium: true, 32 | optimizedProxiesEnabled: optimizedProxiesEnabled, 33 | passportLevel: passportLevel, 34 | customPassport: store.state.customPassportEnabled 35 | ? store.state.customPassport 36 | : null, 37 | fullModeEnabled: 38 | !optimizedProxiesEnabled || 39 | (requestFilter != null && requestFilter.includes(requestType)), 40 | }); 41 | const proxyPassportRequests = isRequestTypeProxied( 42 | ProxyRequestType.Passport, 43 | getRequestParams(ProxyRequestType.Passport) 44 | ); 45 | const proxyUsherRequests = isRequestTypeProxied( 46 | ProxyRequestType.Usher, 47 | getRequestParams(ProxyRequestType.Usher) 48 | ); 49 | const proxyVideoWeaverRequests = isRequestTypeProxied( 50 | ProxyRequestType.VideoWeaver, 51 | getRequestParams(ProxyRequestType.VideoWeaver) 52 | ); 53 | const proxyGraphQLRequests = isRequestTypeProxied( 54 | ProxyRequestType.GraphQL, 55 | getRequestParams(ProxyRequestType.GraphQL) 56 | ); 57 | const proxyTwitchWebpageRequests = isRequestTypeProxied( 58 | ProxyRequestType.TwitchWebpage, 59 | getRequestParams(ProxyRequestType.TwitchWebpage) 60 | ); 61 | 62 | const config: chrome.proxy.ProxyConfig = { 63 | mode: "pac_script", 64 | pacScript: { 65 | data: ` 66 | function FindProxyForURL(url, host) { 67 | // Passport requests. 68 | if (${proxyPassportRequests} && ${passportHostRegex}.test(host)) { 69 | return "${proxyInfoString}"; 70 | } 71 | // Usher requests. 72 | if (${proxyUsherRequests} && ${usherHostRegex}.test(host)) { 73 | return "${proxyInfoString}"; 74 | } 75 | // Video Weaver requests. 76 | if (${proxyVideoWeaverRequests} && ${videoWeaverHostRegex}.test(host)) { 77 | return "${proxyInfoString}"; 78 | } 79 | // GraphQL requests. 80 | if (${proxyGraphQLRequests} && ${twitchGqlHostRegex}.test(host)) { 81 | return "${proxyInfoString}"; 82 | } 83 | // Twitch webpage requests. 84 | if (${proxyTwitchWebpageRequests} && ${twitchTvHostRegex}.test(host)) { 85 | return "${proxyInfoString}"; 86 | } 87 | return "DIRECT"; 88 | } 89 | `, 90 | }, 91 | }; 92 | 93 | chrome.proxy.settings.set({ value: config, scope: "regular" }, function () { 94 | console.log( 95 | `⚙️ Proxying requests through one of: ${proxies.toString() || ""}` 96 | ); 97 | }); 98 | // Keep below code out of the callback to ensure state is updated immediately. 99 | // Otherwise, some requests (e.g. GQLToken) might not get proxied 100 | // (full mode activation not calling `updateProxySettings`). 101 | store.state.chromiumProxyActive = true; 102 | updateDnsResponses(); 103 | } 104 | 105 | function getProxyInfoStringFromUrls(urls: string[]): string { 106 | return [ 107 | ...urls.map(url => { 108 | const proxyInfo = getProxyInfoFromUrl(url); 109 | return `${PROXY_TYPE_MAP[proxyInfo.type]} ${getUrlFromProxyInfo({ 110 | ...proxyInfo, 111 | // Don't include username/password in PAC script. 112 | username: undefined, 113 | password: undefined, 114 | })}`; 115 | }), 116 | "DIRECT", 117 | ].join("; "); 118 | } 119 | 120 | export function clearProxySettings() { 121 | chrome.proxy.settings.clear({ scope: "regular" }, function () { 122 | console.log("⚙️ Proxy settings cleared"); 123 | }); 124 | store.state.chromiumProxyActive = false; 125 | } 126 | -------------------------------------------------------------------------------- /src/common/ts/adLog.ts: -------------------------------------------------------------------------------- 1 | import store from "../../store"; 2 | import getHostFromUrl from "./getHostFromUrl"; 3 | 4 | /** 5 | * Resolve ad identity information for a given ad log entry index. 6 | * @param index 7 | * @param timeout 8 | * @returns True if the identity was successfully resolved or already exists, false otherwise. 9 | */ 10 | export async function resolveAdIdentity( 11 | index: number, 12 | timeout?: number 13 | ): Promise { 14 | if (!(0 <= index && index < store.state.adLog.length)) return false; 15 | if (store.state.adLog[index].adIdentity) return true; // Already resolved. 16 | if (!store.state.adLog[index].parsedLine?.adLineItemId) return false; 17 | 18 | // Try to find existing identity in other log entries first. 19 | if ( 20 | store.state.adLog[index].parsedLine.adDsaAdvertiserId != null && 21 | store.state.adLog[index].parsedLine.adDsaCampaignId != null 22 | ) { 23 | const entry = store.state.adLog.find( 24 | e => 25 | e.parsedLine?.adDsaAdvertiserId === 26 | store.state.adLog[index].parsedLine?.adDsaAdvertiserId && 27 | e.parsedLine?.adDsaCampaignId === 28 | store.state.adLog[index].parsedLine?.adDsaCampaignId && 29 | e.adIdentity !== undefined 30 | ); 31 | if (entry) { 32 | store.state.adLog[index].adIdentity = entry.adIdentity; 33 | return true; 34 | } 35 | } 36 | const entry = store.state.adLog.find( 37 | e => 38 | e.parsedLine?.adLineItemId === 39 | store.state.adLog[index].parsedLine?.adLineItemId && 40 | e.adIdentity !== undefined 41 | ); 42 | if (entry) { 43 | store.state.adLog[index].adIdentity = entry.adIdentity; 44 | return true; 45 | } 46 | 47 | try { 48 | const response = await fetch("https://gql.twitch.tv/gql", { 49 | method: "POST", 50 | headers: { 51 | Accept: "*/*", 52 | "Accept-Language": "en-US", 53 | "Client-Id": "kimne78kx3ncx6brgo4mv6wki5h1ko", 54 | "Content-Type": "text/plain;charset=UTF-8", 55 | }, 56 | body: JSON.stringify([ 57 | { 58 | operationName: "DSAWizard_Query", 59 | variables: { 60 | adInput: { 61 | adIDValue: store.state.adLog[index].parsedLine.adLineItemId, 62 | advertiserIDNS: 63 | store.state.adLog[index].parsedLine.adDsaAdvertiserId ?? "", 64 | campaignIDNS: 65 | store.state.adLog[index].parsedLine.adDsaCampaignId ?? "", 66 | selectionSignals: {}, 67 | }, 68 | clientInput: {}, 69 | }, 70 | extensions: { 71 | persistedQuery: { 72 | version: 1, 73 | sha256Hash: 74 | "09eb612f42f0d04651f837c7d1e8c7aa57a2cc9af0c075ce93eb16527b2dc67f", 75 | }, 76 | }, 77 | }, 78 | ]), 79 | signal: timeout ? AbortSignal.timeout(timeout) : undefined, 80 | }); 81 | const json = await response.json(); 82 | const adIdentity = json?.[0]?.["data"]?.["adIdentity"]; 83 | if (adIdentity) { 84 | store.state.adLog[index].adIdentity = { 85 | advertiserName: adIdentity["advertiserName"], 86 | payerName: adIdentity["payerName"], 87 | isIdentityVerified: adIdentity["isIdentityVerified"], 88 | }; 89 | return true; 90 | } else { 91 | store.state.adLog[index].adIdentity = null; 92 | return false; 93 | } 94 | } catch {} 95 | return false; 96 | } 97 | 98 | /** 99 | * Send ad log entries that haven't been sent yet to the server. 100 | * @returns True if the log was successfully sent, false if there was an error, or null if there were no new entries to send. 101 | */ 102 | export async function sendAdLog(): Promise { 103 | const filteredAdLog = store.state.adLog 104 | .filter(entry => entry.timestamp > store.state.adLogLastSent) 105 | .map(entry => ({ 106 | ...entry, 107 | videoWeaverUrl: getHostFromUrl(entry.videoWeaverUrl), 108 | rawLine: undefined, 109 | })); 110 | if (filteredAdLog.length === 0) return null; // No log entries to send. 111 | 112 | let success = false; 113 | try { 114 | const response = await fetch("https://perfprod.com/ttvlolpro/telemetry", { 115 | method: "POST", 116 | headers: { 117 | "Content-Type": "application/json", 118 | "X-Ad-Log-Version": "2", 119 | }, 120 | body: JSON.stringify(filteredAdLog), 121 | }); 122 | success = response.ok; 123 | if (!success) console.error(`${response.status} ${response.statusText}`); 124 | } catch (error) { 125 | console.error(error); 126 | } 127 | 128 | if (success) store.state.adLogLastSent = Date.now(); 129 | return success; 130 | } 131 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | 3 | # Created by https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,macos,node 4 | # Edit at https://www.toptal.com/developers/gitignore?templates=windows,visualstudiocode,macos,node 5 | 6 | ### macOS ### 7 | # General 8 | .DS_Store 9 | .AppleDouble 10 | .LSOverride 11 | 12 | # Icon must end with two \r 13 | Icon 14 | 15 | # Thumbnails 16 | ._* 17 | 18 | # Files that might appear in the root of a volume 19 | .DocumentRevisions-V100 20 | .fseventsd 21 | .Spotlight-V100 22 | .TemporaryItems 23 | .Trashes 24 | .VolumeIcon.icns 25 | .com.apple.timemachine.donotpresent 26 | 27 | # Directories potentially created on remote AFP share 28 | .AppleDB 29 | .AppleDesktop 30 | Network Trash Folder 31 | Temporary Items 32 | .apdisk 33 | 34 | ### macOS Patch ### 35 | # iCloud generated files 36 | *.icloud 37 | 38 | ### Node ### 39 | # Logs 40 | logs 41 | *.log 42 | npm-debug.log* 43 | yarn-debug.log* 44 | yarn-error.log* 45 | lerna-debug.log* 46 | .pnpm-debug.log* 47 | 48 | # Diagnostic reports (https://nodejs.org/api/report.html) 49 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 50 | 51 | # Runtime data 52 | pids 53 | *.pid 54 | *.seed 55 | *.pid.lock 56 | 57 | # Directory for instrumented libs generated by jscoverage/JSCover 58 | lib-cov 59 | 60 | # Coverage directory used by tools like istanbul 61 | coverage 62 | *.lcov 63 | 64 | # nyc test coverage 65 | .nyc_output 66 | 67 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 68 | .grunt 69 | 70 | # Bower dependency directory (https://bower.io/) 71 | bower_components 72 | 73 | # node-waf configuration 74 | .lock-wscript 75 | 76 | # Compiled binary addons (https://nodejs.org/api/addons.html) 77 | build/Release 78 | 79 | # Dependency directories 80 | node_modules/ 81 | jspm_packages/ 82 | 83 | # Snowpack dependency directory (https://snowpack.dev/) 84 | web_modules/ 85 | 86 | # TypeScript cache 87 | *.tsbuildinfo 88 | 89 | # Optional npm cache directory 90 | .npm 91 | 92 | # Optional eslint cache 93 | .eslintcache 94 | 95 | # Optional stylelint cache 96 | .stylelintcache 97 | 98 | # Microbundle cache 99 | .rpt2_cache/ 100 | .rts2_cache_cjs/ 101 | .rts2_cache_es/ 102 | .rts2_cache_umd/ 103 | 104 | # Optional REPL history 105 | .node_repl_history 106 | 107 | # Output of 'npm pack' 108 | *.tgz 109 | 110 | # Yarn Integrity file 111 | .yarn-integrity 112 | 113 | # dotenv environment variable files 114 | .env 115 | .env.development.local 116 | .env.test.local 117 | .env.production.local 118 | .env.local 119 | 120 | # parcel-bundler cache (https://parceljs.org/) 121 | .cache 122 | .parcel-cache 123 | 124 | # Next.js build output 125 | .next 126 | out 127 | 128 | # Nuxt.js build / generate output 129 | .nuxt 130 | dist 131 | 132 | # Gatsby files 133 | .cache/ 134 | # Comment in the public line in if your project uses Gatsby and not Next.js 135 | # https://nextjs.org/blog/next-9-1#public-directory-support 136 | # public 137 | 138 | # vuepress build output 139 | .vuepress/dist 140 | 141 | # vuepress v2.x temp and cache directory 142 | .temp 143 | 144 | # Docusaurus cache and generated files 145 | .docusaurus 146 | 147 | # Serverless directories 148 | .serverless/ 149 | 150 | # FuseBox cache 151 | .fusebox/ 152 | 153 | # DynamoDB Local files 154 | .dynamodb/ 155 | 156 | # TernJS port file 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | .vscode-test 161 | 162 | # yarn v2 163 | .yarn/cache 164 | .yarn/unplugged 165 | .yarn/build-state.yml 166 | .yarn/install-state.gz 167 | .pnp.* 168 | 169 | ### Node Patch ### 170 | # Serverless Webpack directories 171 | .webpack/ 172 | 173 | # Optional stylelint cache 174 | 175 | # SvelteKit build / generate output 176 | .svelte-kit 177 | 178 | ### VisualStudioCode ### 179 | .vscode/* 180 | !.vscode/settings.json 181 | !.vscode/tasks.json 182 | !.vscode/launch.json 183 | !.vscode/extensions.json 184 | !.vscode/*.code-snippets 185 | 186 | # Local History for Visual Studio Code 187 | .history/ 188 | 189 | # Built Visual Studio Code Extensions 190 | *.vsix 191 | 192 | ### VisualStudioCode Patch ### 193 | # Ignore all local history of files 194 | .history 195 | .ionide 196 | 197 | # Support for Project snippet scope 198 | .vscode/*.code-snippets 199 | 200 | # Ignore code-workspaces 201 | *.code-workspace 202 | 203 | ### Windows ### 204 | # Windows thumbnail cache files 205 | Thumbs.db 206 | Thumbs.db:encryptable 207 | ehthumbs.db 208 | ehthumbs_vista.db 209 | 210 | # Dump file 211 | *.stackdump 212 | 213 | # Folder config file 214 | [Dd]esktop.ini 215 | 216 | # Recycle Bin used on file shares 217 | $RECYCLE.BIN/ 218 | 219 | # Windows Installer files 220 | *.cab 221 | *.msi 222 | *.msix 223 | *.msm 224 | *.msp 225 | 226 | # Windows shortcuts 227 | *.lnk 228 | 229 | # End of https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,macos,node 230 | 231 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) 232 | 233 | releases/ 234 | manifest.json 235 | -------------------------------------------------------------------------------- /src/setup/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --body-background-color: #0e0f11; 3 | --container-background-color: #151619; 4 | --container-box-shadow-color: #0c0c0e; 5 | --container-width: 1100px; 6 | 7 | --font-primary: 8 | "Inter", "Roobert", "Helvetica Neue", Helvetica, Arial, sans-serif; 9 | --logo-size: 6rem; 10 | 11 | --brand-color: #aa51b8; 12 | --text-primary: #e4e6e7; 13 | --text-secondary: #8d9296; 14 | --link: #be68ce; 15 | --link-hover: #cc88d8; 16 | 17 | --input-background-color: #1d1f23; 18 | --input-background-color-disabled: #2e3138; 19 | --input-border-color: #353840; 20 | --input-text-primary: #c3c4ca; 21 | --input-text-secondary: #7a8085; 22 | --input-max-width: 450px; 23 | 24 | --button-background-color: #353840; 25 | --button-background-color-hover: #464953; 26 | --button-text-primary: #c3c4ca; 27 | } 28 | 29 | ::-moz-selection, 30 | ::selection { 31 | background-color: var(--brand-color); 32 | color: #ffffff; 33 | } 34 | 35 | a, 36 | a:visited { 37 | color: var(--link); 38 | transition: color 100ms ease-in-out; 39 | } 40 | a:hover, 41 | a:visited:hover { 42 | color: var(--link-hover); 43 | } 44 | 45 | input[type="button"], 46 | input[type="submit"], 47 | button { 48 | margin: 0.125rem 0; 49 | padding: 0.5rem 1rem; 50 | border: 0; 51 | border-radius: 6px; 52 | background-color: var(--button-background-color); 53 | color: var(--button-text-primary); 54 | cursor: pointer; 55 | transition: background-color 100ms ease-in-out; 56 | } 57 | input[type="button"]:hover:enabled, 58 | input[type="submit"]:hover:enabled, 59 | button:hover:enabled { 60 | background-color: var(--button-background-color-hover); 61 | } 62 | .button-primary { 63 | background-color: var(--brand-color); 64 | color: #ffffff; 65 | } 66 | .button-primary:hover:enabled { 67 | background-color: var(--link-hover); 68 | } 69 | 70 | li.hide-marker::marker { 71 | display: none; 72 | content: ""; 73 | } 74 | 75 | fieldset { 76 | margin: 0; 77 | padding: 0; 78 | border: 0; 79 | } 80 | 81 | small { 82 | color: var(--text-secondary); 83 | font-size: 9pt; 84 | } 85 | 86 | body { 87 | margin: 0; 88 | padding: 0; 89 | background-image: url("../common/images/options_bg.png"); 90 | background-repeat: repeat; 91 | background-color: var(--body-background-color); 92 | color: var(--text-primary); 93 | accent-color: var(--brand-color); 94 | font-size: 100%; 95 | font-family: var(--font-primary); 96 | } 97 | body > .container:first-child { 98 | display: flex; 99 | flex-direction: column; 100 | align-items: center; 101 | justify-content: center; 102 | width: min(var(--container-width), 100%); 103 | min-height: 100vh; 104 | margin: 0 auto; 105 | padding: 2rem; 106 | background-color: var(--container-background-color); 107 | box-shadow: 0 0 32px var(--container-box-shadow-color); 108 | } 109 | 110 | header { 111 | display: flex; 112 | flex-direction: column; 113 | align-items: center; 114 | justify-content: center; 115 | margin: 0 0 3rem 0; 116 | gap: 1rem; 117 | background-color: var(--container-background-color); 118 | text-align: center; 119 | } 120 | header .logo { 121 | width: var(--logo-size); 122 | height: var(--logo-size); 123 | margin: 0; 124 | padding: 0; 125 | } 126 | header .title { 127 | margin: 0; 128 | padding: 0; 129 | font-size: 1.75rem; 130 | } 131 | 132 | .setup-form { 133 | max-width: 800px; 134 | padding: 0 1.5rem; 135 | } 136 | .setup-form > *:first-child { 137 | margin-top: 0; 138 | } 139 | 140 | .segmented-control { 141 | display: flex; 142 | align-items: center; 143 | justify-content: center; 144 | width: fit-content; 145 | margin: 2rem auto 0 auto; 146 | padding: 0; 147 | overflow: hidden; 148 | border: 1px solid var(--input-border-color); 149 | border-radius: 100vmax; 150 | background-color: var(--input-background-color); 151 | } 152 | .segmented-control:has(.segment input:focus-visible) { 153 | border: 1px solid white; 154 | outline: 1px solid white; 155 | } 156 | .segmented-control .segment { 157 | position: relative; 158 | margin: 0; 159 | padding: 0; 160 | overflow: hidden; 161 | border-radius: 100vmax; 162 | text-align: center; 163 | } 164 | .segmented-control .segment input { 165 | appearance: none; 166 | position: absolute; 167 | top: -100vmax; 168 | } 169 | .segmented-control .segment label { 170 | display: block; 171 | margin: 0; 172 | padding: 0.75rem 1.5rem; 173 | font-size: 0.9rem; 174 | cursor: pointer; 175 | transition: box-shadow 100ms ease-in-out; 176 | } 177 | .segmented-control .segment input:checked + label { 178 | background-color: var(--brand-color); 179 | color: #ffffff; 180 | } 181 | .segmented-control .segment label:hover { 182 | box-shadow: inset 0 0 100vmax 100vmax rgba(255, 255, 255, 0.075); 183 | } 184 | 185 | #done-button { 186 | display: block; 187 | width: max-content; 188 | margin: 2rem auto 0 auto; 189 | font-size: 1rem; 190 | } 191 | 192 | footer { 193 | margin: 3rem 0 0 0; 194 | } 195 | 196 | @media screen and (max-width: 600px) { 197 | body > .container:first-child { 198 | padding: 0.5rem; 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Icon 3 |
4 | TTV LOL PRO 5 |
6 |

7 | 8 | 33 | 34 |
35 | 36 | Discord server 40 | 41 |
42 | 43 |
44 | 45 | 63 | 64 |
65 | 66 | > [!NOTE] 67 | > Looking for TTV LOL PRO v1? [Click here](https://github.com/younesaassila/ttv-lol-pro/tree/v1). 68 | 69 | TTV LOL PRO removes most livestream ads from Twitch. 70 | 71 | TTV LOL PRO is a fork of TTV LOL that: 72 | 73 | - uses an improved ad blocking method, 74 | - uses standard HTTP proxies (thus improving proxy compatibility and your privacy), 75 | - adds a stream status widget to the popup, 76 | - lets you whitelist channels, 77 | - lets you use your own proxies. 78 | 79 | TTV LOL PRO does not remove banner ads, nor does it remove ads from VODs. Please use [uBlock Origin](https://ublockorigin.com/) alongside TTV LOL PRO. Without it, we can't guarantee the effectiveness of the extension. 80 | 81 | Any questions? Please read the [wiki](https://wiki.ttvlolpro.com/) first. 82 | 83 | ## Screenshots 84 | 85 |
86 | Popup (Firefox) 87 |
88 | Popup (Firefox) 93 |
94 |
95 | 96 |
97 | Options page (Firefox) 98 |
99 | Options page (Firefox) 104 |
105 |
106 | 107 | ## Installation 108 | 109 | > [!WARNING] 110 | > Please remove any other Twitch-specific ad blocker (this includes the [VAFT](https://github.com/pixeltris/TwitchAdSolutions#scripts) script). [uBlock Origin](https://ublockorigin.com/) is recommended as it is a general-purpose ad blocker. Please clear your browser's cache after installing the extension. 111 | 112 | ### Chrome Web Store 113 | 114 | Download the extension from the [Chrome Web Store](https://chrome.google.com/webstore/detail/ttv-lol-pro/bpaoeijjlplfjbagceilcgbkcdjbomjd). 115 | 116 | ### Firefox Add-ons 117 | 118 | Download the extension from [Firefox Add-ons](https://addons.mozilla.org/addon/ttv-lol-pro/). 119 | 120 | ### Manual installation 121 | 122 | 1. Download the version for your browser under the "Assets" section of the [latest release](https://github.com/younesaassila/ttv-lol-pro/releases). 123 | 124 | 1. - **Chrome _(Permanent)_:** Unzip > Go to `chrome://extensions` > Enable developer mode > Load unpacked 125 | - **Firefox all editions _(Temporary)_:** Go to `about:debugging#/runtime/this-firefox` > Load Temporary Add-on 126 | - **[Firefox Developer Edition](https://www.mozilla.org/en-US/firefox/developer/) _(Permanent)_:** Go to `about:config` > Set `xpinstall.signatures.required` to `false` > Extensions page > Gear > Install Add-on From File 127 | 128 | ## Contributing 129 | 130 | Contributions are welcome! Please read the [contributing guidelines](CONTRIBUTING.md). 131 | 132 | ## Credits 133 | 134 | Extension maintained by [Younes Aassila (@younesaassila)](https://github.com/younesaassila) [ [GitHub Sponsors](https://github.com/sponsors/younesaassila) ] 135 | 136 | Proxies maintained by [Marc Gómez (@zGato)](https://github.com/zGato) [ [Ko-fi](https://ko-fi.com/zGato) | [GitHub Sponsors](https://github.com/sponsors/zGato) ] 137 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [contact@aassila.com](mailto:contact@aassila.com). 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /src/background/handlers/onProxyRequest.ts: -------------------------------------------------------------------------------- 1 | import browser, { Proxy } from "webextension-polyfill"; 2 | import findChannelFromTwitchTvUrl from "../../common/ts/findChannelFromTwitchTvUrl"; 3 | import findChannelFromUsherUrl from "../../common/ts/findChannelFromUsherUrl"; 4 | import findChannelFromVideoWeaverUrl from "../../common/ts/findChannelFromVideoWeaverUrl"; 5 | import getHostFromUrl from "../../common/ts/getHostFromUrl"; 6 | import isChannelWhitelisted from "../../common/ts/isChannelWhitelisted"; 7 | import isFlaggedRequest from "../../common/ts/isFlaggedRequest"; 8 | import isRequestTypeProxied from "../../common/ts/isRequestTypeProxied"; 9 | import { getProxyInfoFromUrl } from "../../common/ts/proxyInfo"; 10 | import { 11 | passportHostRegex, 12 | twitchGqlHostRegex, 13 | twitchTvHostRegex, 14 | usherHostRegex, 15 | videoWeaverHostRegex, 16 | } from "../../common/ts/regexes"; 17 | import store from "../../store"; 18 | import { ProxyInfo, ProxyRequestType } from "../../types"; 19 | 20 | export default async function onProxyRequest( 21 | details: Proxy.OnRequestDetailsType 22 | ): Promise { 23 | // Wait for the store to be loaded. 24 | if (store.readyState !== "complete") { 25 | await new Promise(resolve => { 26 | const listener = () => { 27 | store.removeEventListener("load", listener); 28 | resolve(); 29 | }; 30 | store.addEventListener("load", listener); 31 | }); 32 | } 33 | 34 | const host = getHostFromUrl(details.url); 35 | if (!host) return { type: "direct" }; 36 | 37 | const documentHost = details.documentUrl 38 | ? getHostFromUrl(details.documentUrl) 39 | : null; 40 | // Twitch requests from non-Twitch hosts are not supported. 41 | if ( 42 | documentHost != null && // Twitch webpage requests have no document URL. 43 | !passportHostRegex.test(documentHost) && // Passport requests have a `passport.twitch.tv` document URL. 44 | !twitchTvHostRegex.test(documentHost) 45 | ) { 46 | return { type: "direct" }; 47 | } 48 | 49 | const proxies = store.state.optimizedProxiesEnabled 50 | ? store.state.optimizedProxies 51 | : store.state.normalProxies; 52 | const proxyInfoArray = getProxyInfoArrayFromUrls(proxies); 53 | 54 | const requestParams = { 55 | isChromium: false, 56 | optimizedProxiesEnabled: store.state.optimizedProxiesEnabled, 57 | passportLevel: store.state.passportLevel, 58 | customPassport: store.state.customPassportEnabled 59 | ? store.state.customPassport 60 | : null, 61 | isFlagged: isFlaggedRequest(details.requestHeaders), 62 | }; 63 | const proxyPassportRequest = isRequestTypeProxied( 64 | ProxyRequestType.Passport, 65 | requestParams 66 | ); 67 | const proxyUsherRequest = isRequestTypeProxied( 68 | ProxyRequestType.Usher, 69 | requestParams 70 | ); 71 | const proxyVideoWeaverRequest = isRequestTypeProxied( 72 | ProxyRequestType.VideoWeaver, 73 | requestParams 74 | ); 75 | const proxyGraphQLRequest = isRequestTypeProxied( 76 | ProxyRequestType.GraphQL, 77 | requestParams 78 | ); 79 | const proxyTwitchWebpageRequest = isRequestTypeProxied( 80 | ProxyRequestType.TwitchWebpage, 81 | requestParams 82 | ); 83 | 84 | // Passport requests. 85 | if (proxyPassportRequest && passportHostRegex.test(host)) { 86 | console.log( 87 | `⌛ Proxying ${details.url} through one of: ${ 88 | proxies.toString() || "" 89 | }` 90 | ); 91 | return proxyInfoArray; 92 | } 93 | 94 | // Usher requests. 95 | if (proxyUsherRequest && usherHostRegex.test(host)) { 96 | if (details.url.includes("/vod/")) { 97 | console.log(`✋ '${details.url}' is a VOD manifest.`); 98 | return { type: "direct" }; 99 | } 100 | const channelName = findChannelFromUsherUrl(details.url); 101 | if (isChannelWhitelisted(channelName)) { 102 | console.log(`✋ Channel '${channelName}' is whitelisted.`); 103 | return { type: "direct" }; 104 | } 105 | console.log( 106 | `⌛ Proxying ${details.url} (${ 107 | channelName ?? "unknown" 108 | }) through one of: ${proxies.toString() || ""}` 109 | ); 110 | return proxyInfoArray; 111 | } 112 | 113 | // Video Weaver requests. 114 | if (proxyVideoWeaverRequest && videoWeaverHostRegex.test(host)) { 115 | let tabUrl: string | undefined = undefined; 116 | try { 117 | const tab = await browser.tabs.get(details.tabId); 118 | tabUrl = tab.url; 119 | } catch {} 120 | const channelName = 121 | findChannelFromVideoWeaverUrl(details.url) ?? 122 | findChannelFromTwitchTvUrl(tabUrl); 123 | if (isChannelWhitelisted(channelName)) { 124 | console.log(`✋ Channel '${channelName}' is whitelisted.`); 125 | return { type: "direct" }; 126 | } 127 | console.log( 128 | `⌛ Proxying ${details.url} (${ 129 | channelName ?? "unknown" 130 | }) through one of: ${proxies.toString() || ""}` 131 | ); 132 | return proxyInfoArray; 133 | } 134 | 135 | // Twitch GraphQL requests. 136 | if (proxyGraphQLRequest && twitchGqlHostRegex.test(host)) { 137 | console.log( 138 | `⌛ Proxying ${details.url} through one of: ${ 139 | proxies.toString() || "" 140 | }` 141 | ); 142 | return proxyInfoArray; 143 | } 144 | 145 | // Twitch webpage requests. 146 | if (proxyTwitchWebpageRequest && twitchTvHostRegex.test(host)) { 147 | console.log( 148 | `⌛ Proxying ${details.url} through one of: ${ 149 | proxies.toString() || "" 150 | }` 151 | ); 152 | return proxyInfoArray; 153 | } 154 | 155 | return { type: "direct" }; 156 | } 157 | 158 | function getProxyInfoArrayFromUrls(urls: string[]): ProxyInfo[] { 159 | return [ 160 | ...urls.map(getProxyInfoFromUrl), 161 | { type: "direct" } as ProxyInfo, // Fallback to direct connection if all proxies fail. 162 | ]; 163 | } 164 | -------------------------------------------------------------------------------- /src/common/ts/countryCodes.ts: -------------------------------------------------------------------------------- 1 | export const alpha2 = { 2 | AD: "Andorra", 3 | AE: "United Arab Emirates", 4 | AF: "Afghanistan", 5 | AG: "Antigua and Barbuda", 6 | AI: "Anguilla", 7 | AL: "Albania", 8 | AM: "Armenia", 9 | AO: "Angola", 10 | AQ: "Antarctica", 11 | AR: "Argentina", 12 | AS: "American Samoa", 13 | AT: "Austria", 14 | AU: "Australia", 15 | AW: "Aruba", 16 | AX: "Åland Islands", 17 | AZ: "Azerbaijan", 18 | BA: "Bosnia and Herzegovina", 19 | BB: "Barbados", 20 | BD: "Bangladesh", 21 | BE: "Belgium", 22 | BF: "Burkina Faso", 23 | BG: "Bulgaria", 24 | BH: "Bahrain", 25 | BI: "Burundi", 26 | BJ: "Benin", 27 | BL: "Saint Barthélemy", 28 | BM: "Bermuda", 29 | BN: "Brunei", 30 | BO: "Bolivia", 31 | BQ: "Bonaire, Sint Eustatius and Saba", 32 | BR: "Brazil", 33 | BS: "Bahamas", 34 | BT: "Bhutan", 35 | BV: "Bouvet Island", 36 | BW: "Botswana", 37 | BY: "Belarus", 38 | BZ: "Belize", 39 | CA: "Canada", 40 | CC: "Cocos (Keeling) Islands", 41 | CD: "Democratic Republic of the Congo", 42 | CF: "Central African Republic", 43 | CG: "Republic of the Congo", 44 | CH: "Switzerland", 45 | CI: "Côte d'Ivoire", 46 | CK: "Cook Islands", 47 | CL: "Chile", 48 | CM: "Cameroon", 49 | CN: "China", 50 | CO: "Colombia", 51 | CR: "Costa Rica", 52 | CU: "Cuba", 53 | CV: "Cabo Verde", 54 | CW: "Curaçao", 55 | CX: "Christmas Island", 56 | CY: "Cyprus", 57 | CZ: "Czechia", 58 | DE: "Germany", 59 | DJ: "Djibouti", 60 | DK: "Denmark", 61 | DM: "Dominica", 62 | DO: "Dominican Republic", 63 | DZ: "Algeria", 64 | EC: "Ecuador", 65 | EE: "Estonia", 66 | EG: "Egypt", 67 | EH: "Western Sahara", 68 | ER: "Eritrea", 69 | ES: "Spain", 70 | ET: "Ethiopia", 71 | FI: "Finland", 72 | FJ: "Fiji", 73 | FK: "Falkland Islands", 74 | FM: "Micronesia", 75 | FO: "Faroe Islands", 76 | FR: "France", 77 | GA: "Gabon", 78 | GB: "United Kingdom", 79 | GD: "Grenada", 80 | GE: "Georgia", 81 | GF: "French Guiana", 82 | GG: "Guernsey", 83 | GH: "Ghana", 84 | GI: "Gibraltar", 85 | GL: "Greenland", 86 | GM: "Gambia", 87 | GN: "Guinea", 88 | GP: "Guadeloupe", 89 | GQ: "Equatorial Guinea", 90 | GR: "Greece", 91 | GS: "South Georgia and the South Sandwich Islands", 92 | GT: "Guatemala", 93 | GU: "Guam", 94 | GW: "Guinea-Bissau", 95 | GY: "Guyana", 96 | HK: "Hong Kong", 97 | HM: "Heard Island and McDonald Islands", 98 | HN: "Honduras", 99 | HR: "Croatia", 100 | HT: "Haiti", 101 | HU: "Hungary", 102 | ID: "Indonesia", 103 | IE: "Ireland", 104 | IL: "Israel", 105 | IM: "Isle of Man", 106 | IN: "India", 107 | IO: "British Indian Ocean Territory", 108 | IQ: "Iraq", 109 | IR: "Iran", 110 | IS: "Iceland", 111 | IT: "Italy", 112 | JE: "Jersey", 113 | JM: "Jamaica", 114 | JO: "Jordan", 115 | JP: "Japan", 116 | KE: "Kenya", 117 | KG: "Kyrgyzstan", 118 | KH: "Cambodia", 119 | KI: "Kiribati", 120 | KM: "Comoros", 121 | KN: "Saint Kitts and Nevis", 122 | KP: "North Korea", 123 | KR: "South Korea", 124 | KW: "Kuwait", 125 | KY: "Cayman Islands", 126 | KZ: "Kazakhstan", 127 | LA: "Laos", 128 | LB: "Lebanon", 129 | LC: "Saint Lucia", 130 | LI: "Liechtenstein", 131 | LK: "Sri Lanka", 132 | LR: "Liberia", 133 | LS: "Lesotho", 134 | LT: "Lithuania", 135 | LU: "Luxembourg", 136 | LV: "Latvia", 137 | LY: "Libya", 138 | MA: "Morocco", 139 | MC: "Monaco", 140 | MD: "Moldova", 141 | ME: "Montenegro", 142 | MF: "Saint Martin", 143 | MG: "Madagascar", 144 | MH: "Marshall Islands", 145 | MK: "North Macedonia", 146 | ML: "Mali", 147 | MM: "Myanmar", 148 | MN: "Mongolia", 149 | MO: "Macao", 150 | MP: "Northern Mariana Islands", 151 | MQ: "Martinique", 152 | MR: "Mauritania", 153 | MS: "Montserrat", 154 | MT: "Malta", 155 | MU: "Mauritius", 156 | MV: "Maldives", 157 | MW: "Malawi", 158 | MX: "Mexico", 159 | MY: "Malaysia", 160 | MZ: "Mozambique", 161 | NA: "Namibia", 162 | NC: "New Caledonia", 163 | NE: "Niger", 164 | NF: "Norfolk Island", 165 | NG: "Nigeria", 166 | NI: "Nicaragua", 167 | NL: "Netherlands", 168 | NO: "Norway", 169 | NP: "Nepal", 170 | NR: "Nauru", 171 | NU: "Niue", 172 | NZ: "New Zealand", 173 | OM: "Oman", 174 | PA: "Panama", 175 | PE: "Peru", 176 | PF: "French Polynesia", 177 | PG: "Papua New Guinea", 178 | PH: "Philippines", 179 | PK: "Pakistan", 180 | PL: "Poland", 181 | PM: "Saint Pierre and Miquelon", 182 | PN: "Pitcairn", 183 | PR: "Puerto Rico", 184 | PS: "Palestine", 185 | PT: "Portugal", 186 | PW: "Palau", 187 | PY: "Paraguay", 188 | QA: "Qatar", 189 | RE: "Réunion", 190 | RO: "Romania", 191 | RS: "Serbia", 192 | RU: "Russia", 193 | RW: "Rwanda", 194 | SA: "Saudi Arabia", 195 | SB: "Solomon Islands", 196 | SC: "Seychelles", 197 | SD: "Sudan", 198 | SE: "Sweden", 199 | SG: "Singapore", 200 | SH: "Saint Helena, Ascension and Tristan da Cunha", 201 | SI: "Slovenia", 202 | SJ: "Svalbard and Jan Mayen", 203 | SK: "Slovakia", 204 | SL: "Sierra Leone", 205 | SM: "San Marino", 206 | SN: "Senegal", 207 | SO: "Somalia", 208 | SR: "Suriname", 209 | SS: "South Sudan", 210 | ST: "Sao Tome and Principe", 211 | SV: "El Salvador", 212 | SX: "Sint Maarten", 213 | SY: "Syria", 214 | SZ: "Eswatini", 215 | TC: "Turks and Caicos Islands", 216 | TD: "Chad", 217 | TF: "French Southern Territories", 218 | TG: "Togo", 219 | TH: "Thailand", 220 | TJ: "Tajikistan", 221 | TK: "Tokelau", 222 | TL: "Timor-Leste", 223 | TM: "Turkmenistan", 224 | TN: "Tunisia", 225 | TO: "Tonga", 226 | TR: "Türkiye", 227 | TT: "Trinidad and Tobago", 228 | TV: "Tuvalu", 229 | TW: "Taiwan", 230 | TZ: "Tanzania", 231 | UA: "Ukraine", 232 | UG: "Uganda", 233 | UK: "United Kingdom", 234 | UM: "United States Minor Outlying Islands", 235 | US: "United States", 236 | UY: "Uruguay", 237 | UZ: "Uzbekistan", 238 | VA: "Vatican City", 239 | VC: "Saint Vincent and the Grenadines", 240 | VE: "Venezuela", 241 | VG: "Virgin Islands (British)", 242 | VI: "Virgin Islands (U.S.)", 243 | VN: "Vietnam", 244 | VU: "Vanuatu", 245 | WF: "Wallis and Futuna", 246 | WS: "Samoa", 247 | YE: "Yemen", 248 | YT: "Mayotte", 249 | ZA: "South Africa", 250 | ZM: "Zambia", 251 | ZW: "Zimbabwe", 252 | }; 253 | -------------------------------------------------------------------------------- /src/background/handlers/onResponseStarted.ts: -------------------------------------------------------------------------------- 1 | import browser, { WebRequest } from "webextension-polyfill"; 2 | import findChannelFromTwitchTvUrl from "../../common/ts/findChannelFromTwitchTvUrl"; 3 | import findChannelFromUsherUrl from "../../common/ts/findChannelFromUsherUrl"; 4 | import findChannelFromVideoWeaverUrl from "../../common/ts/findChannelFromVideoWeaverUrl"; 5 | import getHostFromUrl from "../../common/ts/getHostFromUrl"; 6 | import { normalizeIpAddress } from "../../common/ts/ipAddress"; 7 | import isChromium from "../../common/ts/isChromium"; 8 | import isRequestTypeProxied from "../../common/ts/isRequestTypeProxied"; 9 | import { 10 | getProxyInfoFromUrl, 11 | getUrlFromProxyInfo, 12 | } from "../../common/ts/proxyInfo"; 13 | import { 14 | passportHostRegex, 15 | twitchGqlHostRegex, 16 | twitchTvHostRegex, 17 | usherHostRegex, 18 | videoWeaverHostRegex, 19 | } from "../../common/ts/regexes"; 20 | import { getStreamStatus, setStreamStatus } from "../../common/ts/streamStatus"; 21 | import store from "../../store"; 22 | import { ProxyInfo, ProxyRequestType } from "../../types"; 23 | 24 | export default async function onResponseStarted( 25 | details: WebRequest.OnResponseStartedDetailsType & { 26 | proxyInfo?: ProxyInfo; 27 | } 28 | ): Promise { 29 | const host = getHostFromUrl(details.url); 30 | if (!host) return; 31 | 32 | let proxy: string | null = null; 33 | let errorMessage: string | null = null; 34 | try { 35 | proxy = getProxyFromDetails(details); 36 | } catch (error) { 37 | errorMessage = error instanceof Error ? error.message : `${error}`; 38 | } 39 | 40 | const requestParams = { 41 | isChromium: isChromium, 42 | optimizedProxiesEnabled: store.state.optimizedProxiesEnabled, 43 | passportLevel: store.state.passportLevel, 44 | customPassport: store.state.customPassportEnabled 45 | ? store.state.customPassport 46 | : null, 47 | }; 48 | const proxiedPassportRequest = isRequestTypeProxied( 49 | ProxyRequestType.Passport, 50 | requestParams 51 | ); 52 | const proxiedUsherRequest = isRequestTypeProxied( 53 | ProxyRequestType.Usher, 54 | requestParams 55 | ); 56 | const proxiedVideoWeaverRequest = isRequestTypeProxied( 57 | ProxyRequestType.VideoWeaver, 58 | requestParams 59 | ); 60 | const proxiedGraphQLRequest = isRequestTypeProxied( 61 | ProxyRequestType.GraphQL, 62 | requestParams 63 | ); 64 | const proxiedTwitchWebpageRequest = isRequestTypeProxied( 65 | ProxyRequestType.TwitchWebpage, 66 | requestParams 67 | ); 68 | 69 | // Passport requests. 70 | if (proxiedPassportRequest && passportHostRegex.test(host)) { 71 | if (!proxy) return console.log(`❌ Did not proxy ${details.url}`); 72 | console.log(`✅ Proxied ${details.url} through ${proxy}`); 73 | } 74 | 75 | // Usher requests. 76 | if (proxiedUsherRequest && usherHostRegex.test(host)) { 77 | let channelName = findChannelFromUsherUrl(details.url); 78 | if (!channelName) { 79 | try { 80 | const tab = await browser.tabs.get(details.tabId); 81 | channelName = findChannelFromTwitchTvUrl(tab.url); 82 | } catch {} 83 | } 84 | await updateStreamStatus(channelName, proxy, errorMessage); 85 | 86 | if (!proxy) { 87 | return console.log( 88 | `❌ Did not proxy ${details.url} (${channelName ?? "unknown"})` 89 | ); 90 | } 91 | console.log( 92 | `✅ Proxied ${details.url} (${channelName ?? "unknown"}) through ${proxy}` 93 | ); 94 | } 95 | 96 | // Video-weaver requests. 97 | if (proxiedVideoWeaverRequest && videoWeaverHostRegex.test(host)) { 98 | let channelName = findChannelFromVideoWeaverUrl(details.url); 99 | if (!channelName) { 100 | try { 101 | const tab = await browser.tabs.get(details.tabId); 102 | channelName = findChannelFromTwitchTvUrl(tab.url); 103 | } catch {} 104 | } 105 | await updateStreamStatus(channelName, proxy, errorMessage); 106 | 107 | if (!proxy) { 108 | return console.log( 109 | `❌ Did not proxy ${details.url} (${channelName ?? "unknown"})` 110 | ); 111 | } 112 | console.log( 113 | `✅ Proxied ${details.url} (${channelName ?? "unknown"}) through ${proxy}` 114 | ); 115 | } 116 | 117 | // Twitch GraphQL requests. 118 | if (proxiedGraphQLRequest && twitchGqlHostRegex.test(host)) { 119 | if (!proxy && store.state.optimizedProxiesEnabled) return; // Expected for most requests. 120 | if (!proxy) return console.log(`❌ Did not proxy ${details.url}`); 121 | console.log(`✅ Proxied ${details.url} through ${proxy}`); 122 | } 123 | 124 | // Twitch webpage requests. 125 | if (proxiedTwitchWebpageRequest && twitchTvHostRegex.test(host)) { 126 | if (!proxy) return console.log(`❌ Did not proxy ${details.url}`); 127 | console.log(`✅ Proxied ${details.url} through ${proxy}`); 128 | } 129 | } 130 | 131 | function getProxyFromDetails( 132 | details: WebRequest.OnResponseStartedDetailsType & { 133 | proxyInfo?: ProxyInfo; 134 | } 135 | ): string | null { 136 | if (isChromium) { 137 | const proxies = Array.from( 138 | new Set([...store.state.optimizedProxies, ...store.state.normalProxies]) 139 | ); 140 | const isDnsError = 141 | proxies.length !== 0 && store.state.dnsResponses.length === 0; 142 | if (isDnsError) { 143 | throw new Error( 144 | "Cannot detect if requests are being proxied due to a DNS error" 145 | ); 146 | } 147 | const ip = details.ip; 148 | if (!ip) return null; 149 | const normalizedIp = normalizeIpAddress(ip) ?? ip; 150 | const dnsResponse = store.state.dnsResponses.find(dnsResponse => 151 | dnsResponse.ips.some(responseIp => { 152 | const normalizedResponseIp = normalizeIpAddress(responseIp); 153 | return (normalizedResponseIp ?? responseIp) === normalizedIp; 154 | }) 155 | ); 156 | if (!dnsResponse) return null; 157 | const proxyInfoArray = proxies.map(getProxyInfoFromUrl); 158 | const possibleProxies = proxyInfoArray.filter( 159 | proxy => proxy.host === dnsResponse.host 160 | ); 161 | if (possibleProxies.length === 0) return dnsResponse.host; 162 | return getUrlFromProxyInfo(possibleProxies[0]); 163 | } else { 164 | const proxyInfo = details.proxyInfo; // Firefox only. 165 | if (!proxyInfo || proxyInfo.type === "direct") return null; 166 | return getUrlFromProxyInfo(proxyInfo); 167 | } 168 | } 169 | 170 | async function updateStreamStatus( 171 | channelName: string | null, 172 | proxy: string | null, 173 | errorMessage: string | null 174 | ) { 175 | const streamStatus = getStreamStatus(channelName); 176 | const stats = streamStatus?.stats ?? { proxied: 0, notProxied: 0 }; 177 | 178 | if (!proxy) { 179 | stats.notProxied++; 180 | let reason = errorMessage ?? streamStatus?.reason ?? ""; 181 | try { 182 | const proxySettings = await browser.proxy.settings.get({}); 183 | switch (proxySettings.levelOfControl) { 184 | case "controlled_by_other_extensions": 185 | reason = "Proxy settings controlled by other extension"; 186 | break; 187 | case "not_controllable": 188 | reason = "Proxy settings not controllable"; 189 | break; 190 | } 191 | } catch {} 192 | setStreamStatus(channelName, { 193 | proxied: false, 194 | proxyHost: streamStatus?.proxyHost, 195 | proxyCountry: streamStatus?.proxyCountry, 196 | reason, 197 | stats, 198 | }); 199 | return; 200 | } 201 | 202 | stats.proxied++; 203 | setStreamStatus(channelName, { 204 | proxied: true, 205 | proxyHost: proxy, 206 | proxyCountry: streamStatus?.proxyCountry, 207 | reason: "", 208 | stats, 209 | }); 210 | } 211 | -------------------------------------------------------------------------------- /src/popup/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --background: #151619; 3 | --background-secondary: #202127; 4 | --background-tertiary: #292b32; 5 | --popup-width: 300px; 6 | --popup-margin: 18px; 7 | --popup-gap: 12px; 8 | 9 | --font-family: 10 | "Inter", "Roobert", "Helvetica Neue", Helvetica, Arial, sans-serif; 11 | 12 | --brand-color: #aa51b8; 13 | --text-color: #c9cbcd; 14 | --link: #be68ce; 15 | --link-hover: #cc88d8; 16 | 17 | --border-radius: 8px; 18 | 19 | --warning-color: #faa61a; 20 | --warning-background-color: #faa61a33; 21 | --success-color: #43b581; 22 | --idle-color: #777; 23 | --error-color: #f04747; 24 | --whitelisted-color: #baaadb; 25 | } 26 | 27 | ::-moz-selection, 28 | ::selection { 29 | background-color: var(--brand-color); 30 | color: #ffffff; 31 | } 32 | 33 | body { 34 | width: var(--popup-width); 35 | margin: 0; 36 | padding: 0; 37 | background-color: var(--background); 38 | color: var(--text-color); 39 | accent-color: var(--brand-color); 40 | font-family: var(--font-family); 41 | } 42 | 43 | header { 44 | margin: var(--popup-margin) 0 var(--popup-gap) 0; 45 | text-align: center; 46 | } 47 | 48 | header .logo { 49 | height: 40px; 50 | } 51 | 52 | main { 53 | display: flex; 54 | flex-direction: column; 55 | align-items: center; 56 | justify-content: start; 57 | margin: 0 var(--popup-margin); 58 | gap: var(--popup-gap); 59 | } 60 | 61 | main > * { 62 | width: 100%; 63 | margin: 0; 64 | } 65 | 66 | footer { 67 | margin: var(--popup-gap) 0 var(--popup-margin) 0; 68 | text-align: center; 69 | } 70 | 71 | footer .wiki-notice { 72 | color: #999; 73 | font-size: 8pt; 74 | } 75 | 76 | a, 77 | a:visited { 78 | color: var(--link); 79 | transition: color 100ms ease-in-out; 80 | } 81 | a:hover, 82 | a:visited:hover { 83 | color: var(--link-hover); 84 | } 85 | 86 | /* Warning banner */ 87 | 88 | .warning-banner { 89 | display: none; 90 | margin: 3px 3px 0 3px; 91 | padding: 12px; 92 | border: 1px solid var(--warning-color); 93 | border-radius: 5px; 94 | background-color: var(--warning-background-color); 95 | color: #ffffff; 96 | font-size: 9pt; 97 | } 98 | 99 | .warning-banner .warning-banner-title { 100 | margin: 0 0 2px 0; 101 | font-weight: bold; 102 | font-size: 11pt; 103 | } 104 | 105 | .warning-banner .warning-banner-link, 106 | .warning-banner .warning-banner-link:visited { 107 | color: currentColor; 108 | transition: opacity 100ms ease-in-out; 109 | } 110 | 111 | .warning-banner .warning-banner-link:hover, 112 | .warning-banner .warning-banner-link:hover:visited { 113 | color: currentColor; 114 | opacity: 0.8; 115 | } 116 | 117 | /* Stream status */ 118 | 119 | #stream-status { 120 | display: none; 121 | z-index: 0; 122 | flex-direction: column; 123 | } 124 | /* Proxy status */ 125 | #stream-status #proxy-status { 126 | display: grid; 127 | grid-template-rows: min-content min-content min-content; 128 | grid-template-columns: min-content auto; 129 | grid-template-areas: 130 | "top-left top-right" 131 | "middle-left middle-right" 132 | "bottom-left bottom-right"; 133 | column-gap: 10px; 134 | padding: 15px; 135 | overflow: hidden; 136 | border-top-right-radius: var(--border-radius); 137 | border-top-left-radius: var(--border-radius); 138 | background-color: var(--background-secondary); 139 | } 140 | /* Proxy status icon */ 141 | #stream-status #proxied { 142 | display: flex; 143 | position: relative; 144 | grid-area: top-left; 145 | align-items: center; 146 | justify-content: center; 147 | cursor: help; 148 | } 149 | #stream-status #proxied #proxied-dot-shadow { 150 | z-index: 1; /* Fixes visual bug when proxied dot is pulsing */ 151 | position: absolute; 152 | top: 50%; 153 | left: 50%; 154 | width: 8px; 155 | height: 8px; 156 | transform: translate(-50%, -50%) scale(1); 157 | border-radius: 50%; 158 | background-color: var(--background-secondary); 159 | opacity: 0; 160 | } 161 | #stream-status #proxied #proxied-dot { 162 | z-index: 2; 163 | width: 8px; 164 | height: 8px; 165 | border-radius: 50%; 166 | background-color: var(--background-secondary); 167 | } 168 | #stream-status #proxied.pulsing #proxied-dot-shadow { 169 | animation: pulse-shadow 750ms ease-out; 170 | } 171 | #stream-status #proxied.success #proxied-dot-shadow, 172 | #stream-status #proxied.success #proxied-dot { 173 | background-color: var(--success-color); 174 | } 175 | #stream-status #proxied.idle #proxied-dot-shadow, 176 | #stream-status #proxied.idle #proxied-dot { 177 | background-color: var(--idle-color); 178 | } 179 | #stream-status #proxied.error #proxied-dot-shadow, 180 | #stream-status #proxied.error #proxied-dot { 181 | background-color: var(--error-color); 182 | } 183 | @keyframes pulse-shadow { 184 | 0% { 185 | transform: translate(-50%, -50%) scale(1); 186 | opacity: 0.5; 187 | } 188 | 100% { 189 | transform: translate(-50%, -50%) scale(2.25); 190 | opacity: 0; 191 | } 192 | } 193 | /* Proxy status channel name */ 194 | #stream-status #channel-name { 195 | grid-area: top-right; 196 | margin: 0; 197 | overflow: hidden; 198 | font-size: 11pt; 199 | text-overflow: ellipsis; 200 | white-space: nowrap; 201 | } 202 | /* Proxy status reason */ 203 | #stream-status #reason { 204 | grid-area: middle-right; 205 | margin: 4px 0 0 0; 206 | overflow: hidden; 207 | font-size: 9pt; 208 | overflow-wrap: anywhere; 209 | opacity: 0.8; 210 | } 211 | /* Proxy status info */ 212 | #stream-status #info-container { 213 | display: none; 214 | grid-area: bottom-right; 215 | flex-direction: column; 216 | align-items: flex-start; 217 | justify-content: center; 218 | margin: 6px 0 0 0; 219 | gap: 2px; 220 | } 221 | #stream-status .info { 222 | font-size: 7pt; 223 | overflow-wrap: anywhere; 224 | opacity: 0.7; 225 | } 226 | /* Whitelist status */ 227 | #whitelist-status { 228 | position: relative; 229 | height: 40px; 230 | border-top: 1px solid #3d4042; 231 | border-bottom-right-radius: var(--border-radius); 232 | border-bottom-left-radius: var(--border-radius); 233 | background-color: var(--background-secondary); 234 | text-align: center; 235 | transition: background-color 100ms ease-in-out; 236 | } 237 | #whitelist-status:hover { 238 | background-color: var(--background-tertiary); 239 | } 240 | #whitelist-status:has(#whitelist-toggle:focus-visible) { 241 | outline: 2px solid white; 242 | } 243 | /* Whitelist toggle */ 244 | #whitelist-toggle { 245 | appearance: none; 246 | position: absolute; 247 | bottom: -100vmax; 248 | } 249 | /* Whitelist toggle icon */ 250 | #whitelist-status #whitelist-toggle-icon { 251 | display: inline-flex; 252 | align-items: center; 253 | justify-content: center; 254 | width: 20px; 255 | height: 20px; 256 | margin-right: 3px; 257 | } 258 | #whitelist-status[data-whitelisted="false"] #whitelist-toggle-icon .bi-plus { 259 | display: initial; 260 | } 261 | #whitelist-status[data-whitelisted="false"] 262 | #whitelist-toggle-icon 263 | .bi-check-lg { 264 | display: none; 265 | } 266 | #whitelist-status[data-whitelisted="true"] #whitelist-toggle-icon .bi-plus { 267 | display: none; 268 | } 269 | #whitelist-status[data-whitelisted="true"] #whitelist-toggle-icon .bi-check-lg { 270 | display: initial; 271 | color: var(--whitelisted-color); 272 | } 273 | /* Whitelist toggle label */ 274 | #whitelist-status #whitelist-toggle-label { 275 | display: flex; 276 | align-items: center; 277 | justify-content: center; 278 | width: 100%; 279 | height: 100%; 280 | font-weight: bold; 281 | font-size: 11pt; 282 | cursor: pointer; 283 | } 284 | #whitelist-status[data-whitelisted="false"] #whitelist-toggle-label::after { 285 | content: "Whitelist"; 286 | } 287 | #whitelist-status[data-whitelisted="true"] #whitelist-toggle-label::after { 288 | content: "Whitelisted"; 289 | color: var(--whitelisted-color); 290 | } 291 | 292 | /* Lists */ 293 | 294 | .list { 295 | margin: 0; 296 | padding: 0; 297 | list-style: none; 298 | } 299 | 300 | .list li { 301 | border-bottom: 1px solid #3d4042; 302 | } 303 | .list li:last-child { 304 | border-bottom: none; 305 | } 306 | 307 | .list li:first-child .list-item { 308 | border-top-right-radius: var(--border-radius); 309 | border-top-left-radius: var(--border-radius); 310 | } 311 | .list li:last-child .list-item { 312 | border-bottom-right-radius: var(--border-radius); 313 | border-bottom-left-radius: var(--border-radius); 314 | } 315 | 316 | .list .list-item, 317 | .list .list-item:visited { 318 | display: flex; 319 | flex-direction: row; 320 | align-items: center; 321 | justify-content: start; 322 | width: 100%; 323 | padding: 10px 15px; 324 | border: none; 325 | background-color: var(--background-secondary); 326 | color: var(--text-color); 327 | text-align: left; 328 | text-decoration: none; 329 | cursor: pointer; 330 | transition: background-color 100ms ease-in-out; 331 | } 332 | 333 | .list .list-item:hover, 334 | .list .list-item:visited:hover { 335 | background-color: var(--background-tertiary); 336 | } 337 | 338 | .list .list-item .list-item-icon { 339 | display: flex; 340 | align-items: center; 341 | justify-content: center; 342 | width: 16px; 343 | height: 16px; 344 | margin-right: 10px; 345 | } 346 | 347 | .list .list-item .list-item-text { 348 | overflow: hidden; 349 | } 350 | 351 | .list .list-item .list-item-text .list-item-title { 352 | margin: 0 0 2px 0; 353 | overflow: hidden; 354 | font-weight: bold; 355 | font-size: 11pt; 356 | text-overflow: ellipsis; 357 | white-space: nowrap; 358 | } 359 | 360 | .list .list-item .list-item-text .list-item-description { 361 | margin: 0; 362 | font-size: 8pt; 363 | opacity: 0.8; 364 | } 365 | -------------------------------------------------------------------------------- /src/page/page.ts: -------------------------------------------------------------------------------- 1 | import { Mutex } from "async-mutex"; 2 | import findChannelFromTwitchTvUrl from "../common/ts/findChannelFromTwitchTvUrl"; 3 | import Logger from "../common/ts/Logger"; 4 | import toAbsoluteUrl from "../common/ts/toAbsoluteUrl"; 5 | import { MessageType, ProxyRequestType } from "../types"; 6 | import getFetch from "./getFetch"; 7 | import getWorker from "./getWorker"; 8 | import { 9 | getSendMessageToContentScript, 10 | getSendMessageToContentScriptAndWaitForResponse, 11 | getSendMessageToPageScript, 12 | getSendMessageToPageScriptAndWaitForResponse, 13 | getSendMessageToWorkerScripts, 14 | getSendMessageToWorkerScriptsAndWaitForResponse, 15 | } from "./sendMessage"; 16 | import type { PageState } from "./types"; 17 | 18 | const logger = new Logger("Page"); 19 | const performanceNavigationEntry = 20 | performance.getEntriesByType("navigation")[0]; 21 | if (performanceNavigationEntry) { 22 | logger.log( 23 | `Page script running (injected after ${( 24 | performance.now() - performanceNavigationEntry.startTime 25 | ).toFixed(0)}ms since navigation start).` 26 | ); 27 | } else { 28 | logger.log("Page script running."); 29 | } 30 | 31 | if (!document.documentElement.dataset.tlpParams) { 32 | logger.log("Waiting for params from content script…"); 33 | waitForParams() 34 | .then(params => { 35 | if (performanceNavigationEntry) { 36 | logger.log( 37 | `Received params from content script (after ${( 38 | performance.now() - performanceNavigationEntry.startTime 39 | ).toFixed(0)}ms since navigation start).` 40 | ); 41 | } else { 42 | logger.log("Received params from content script."); 43 | } 44 | main(params); 45 | }) 46 | .catch(error => { 47 | logger.error("Failed to get params:", error); 48 | }); 49 | } else { 50 | try { 51 | main(JSON.parse(document.documentElement.dataset.tlpParams)); 52 | } catch (error) { 53 | logger.error("Failed to parse params:", error); 54 | } 55 | } 56 | 57 | async function waitForParams(): Promise { 58 | return new Promise((resolve, reject) => { 59 | const timeout = setTimeout(() => { 60 | observer.disconnect(); 61 | reject(new Error("Timed out waiting for params.")); 62 | }, 10000); // 10 seconds timeout 63 | const observer = new MutationObserver(mutations => { 64 | for (const mutation of mutations) { 65 | if ( 66 | mutation.type === "attributes" && 67 | mutation.attributeName === "data-tlp-params" && 68 | document.documentElement.dataset.tlpParams 69 | ) { 70 | clearTimeout(timeout); 71 | observer.disconnect(); 72 | try { 73 | const params = JSON.parse( 74 | document.documentElement.dataset.tlpParams 75 | ); 76 | resolve(params); 77 | } catch (error) { 78 | reject(error); 79 | } 80 | } 81 | } 82 | }); 83 | observer.observe(document.documentElement, { 84 | attributeFilter: ["data-tlp-params"], 85 | }); 86 | }); 87 | } 88 | 89 | async function main(params: any) { 90 | delete document.documentElement.dataset.tlpParams; 91 | 92 | const broadcastChannel = new BroadcastChannel(params.broadcastChannelName); 93 | const sendMessageToContentScript = 94 | getSendMessageToContentScript(broadcastChannel); 95 | const sendMessageToContentScriptAndWaitForResponse = 96 | getSendMessageToContentScriptAndWaitForResponse(broadcastChannel); 97 | const sendMessageToPageScript = getSendMessageToPageScript(broadcastChannel); 98 | const sendMessageToPageScriptAndWaitForResponse = 99 | getSendMessageToPageScriptAndWaitForResponse(broadcastChannel); 100 | const sendMessageToWorkerScripts = 101 | getSendMessageToWorkerScripts(broadcastChannel); 102 | const sendMessageToWorkerScriptsAndWaitForResponse = 103 | getSendMessageToWorkerScriptsAndWaitForResponse(broadcastChannel); 104 | 105 | const pageState: PageState = { 106 | params: params, 107 | isChromium: params.isChromium, 108 | scope: "page", 109 | state: undefined, 110 | requestTypeMutexes: { 111 | [ProxyRequestType.Passport]: new Mutex(), 112 | [ProxyRequestType.Usher]: new Mutex(), 113 | [ProxyRequestType.VideoWeaver]: new Mutex(), 114 | [ProxyRequestType.GraphQL]: new Mutex(), 115 | [ProxyRequestType.GraphQLToken]: new Mutex(), 116 | [ProxyRequestType.GraphQLIntegrity]: new Mutex(), 117 | [ProxyRequestType.GraphQLAll]: new Mutex(), 118 | [ProxyRequestType.TwitchWebpage]: new Mutex(), 119 | }, 120 | twitchWorkers: [], // No longer used. Might be useful in the future? 121 | sendMessageToContentScript, 122 | sendMessageToContentScriptAndWaitForResponse, 123 | sendMessageToPageScript, 124 | sendMessageToPageScriptAndWaitForResponse, 125 | sendMessageToWorkerScripts, 126 | sendMessageToWorkerScriptsAndWaitForResponse, 127 | }; 128 | 129 | const originalFetch = window.fetch; 130 | const newFetch = getFetch(pageState); 131 | window.fetch = newFetch; 132 | if (window.fetch !== newFetch) { 133 | logger.error("Failed to replace fetch."); 134 | sendMessageToContentScript({ 135 | type: MessageType.ExtensionError, 136 | errorMessage: 137 | "Failed to replace fetch. Are you using another Twitch extension?", 138 | }); 139 | } else { 140 | logger.log("fetch replaced successfully."); 141 | } 142 | 143 | const newWorker = getWorker(pageState); 144 | if (newWorker !== null) { 145 | window.Worker = newWorker; 146 | if (window.Worker !== newWorker) { 147 | logger.error("Failed to replace Worker."); 148 | sendMessageToContentScript({ 149 | type: MessageType.ExtensionError, 150 | errorMessage: 151 | "Failed to replace Worker. Are you using another Twitch ad blocker?", 152 | }); 153 | } else { 154 | logger.log("Worker replaced successfully."); 155 | } 156 | } 157 | if (newWorker === null || window.Worker !== newWorker) { 158 | if (window.fetch === newFetch) { 159 | logger.warn( 160 | "Reverting fetch replacement due to Worker replacement failure." 161 | ); 162 | window.fetch = originalFetch; 163 | } 164 | } 165 | 166 | broadcastChannel.addEventListener("message", event => { 167 | if (!event.data || event.data.type !== MessageType.PageScriptMessage) { 168 | return; 169 | } 170 | 171 | const { message } = event.data; 172 | if (!message) return; 173 | 174 | switch (message.type) { 175 | case MessageType.GetStoreStateResponse: 176 | if (pageState.state == null) { 177 | logger.log("Received store state from content script."); 178 | } else { 179 | logger.debug("Received store state from content script."); 180 | } 181 | const state = message.state; 182 | pageState.state = state; 183 | break; 184 | } 185 | }); 186 | sendMessageToContentScript({ 187 | type: MessageType.GetStoreState, 188 | from: pageState.scope, 189 | }); 190 | 191 | function onChannelChange( 192 | callback: (channelName: string, oldChannelName: string | null) => void 193 | ) { 194 | let channelName: string | null = findChannelFromTwitchTvUrl(location.href); 195 | 196 | const NATIVE_PUSH_STATE = window.history.pushState; 197 | function pushState( 198 | data: any, 199 | unused: string, 200 | url?: string | URL | null | undefined 201 | ) { 202 | if (!url) return NATIVE_PUSH_STATE.call(window.history, data, unused); 203 | const fullUrl = toAbsoluteUrl(url.toString()); 204 | const newChannelName = findChannelFromTwitchTvUrl(fullUrl); 205 | if (newChannelName != null && newChannelName !== channelName) { 206 | const oldChannelName = channelName; 207 | channelName = newChannelName; 208 | callback(channelName, oldChannelName); 209 | } 210 | return NATIVE_PUSH_STATE.call(window.history, data, unused, url); 211 | } 212 | window.history.pushState = pushState; 213 | 214 | const NATIVE_REPLACE_STATE = window.history.replaceState; 215 | function replaceState( 216 | data: any, 217 | unused: string, 218 | url?: string | URL | null | undefined 219 | ) { 220 | if (!url) return NATIVE_REPLACE_STATE.call(window.history, data, unused); 221 | const fullUrl = toAbsoluteUrl(url.toString()); 222 | const newChannelName = findChannelFromTwitchTvUrl(fullUrl); 223 | if (newChannelName != null && newChannelName !== channelName) { 224 | const oldChannelName = channelName; 225 | channelName = newChannelName; 226 | callback(channelName, oldChannelName); 227 | } 228 | return NATIVE_REPLACE_STATE.call(window.history, data, unused, url); 229 | } 230 | window.history.replaceState = replaceState; 231 | 232 | window.addEventListener("popstate", () => { 233 | const newChannelName = findChannelFromTwitchTvUrl(location.href); 234 | if (newChannelName != null && newChannelName !== channelName) { 235 | const oldChannelName = channelName; 236 | channelName = newChannelName; 237 | callback(channelName, oldChannelName); 238 | } 239 | }); 240 | } 241 | onChannelChange((_channelName, oldChannelName) => { 242 | sendMessageToContentScript({ 243 | type: MessageType.ClearStats, 244 | channelName: oldChannelName, 245 | }); 246 | sendMessageToPageScript({ 247 | type: MessageType.ClearStats, 248 | channelName: oldChannelName, 249 | }); 250 | sendMessageToWorkerScripts({ 251 | type: MessageType.ClearStats, 252 | channelName: oldChannelName, 253 | }); 254 | }); 255 | } 256 | -------------------------------------------------------------------------------- /src/content/content.ts: -------------------------------------------------------------------------------- 1 | import workerScriptURL from "url:../page/worker.ts"; 2 | import browser, { Storage } from "webextension-polyfill"; 3 | import { resolveAdIdentity } from "../common/ts/adLog"; 4 | import findChannelFromTwitchTvUrl from "../common/ts/findChannelFromTwitchTvUrl"; 5 | import generateRandomString from "../common/ts/generateRandomString"; 6 | import isChannelWhitelisted from "../common/ts/isChannelWhitelisted"; 7 | import isChromium from "../common/ts/isChromium"; 8 | import Logger from "../common/ts/Logger"; 9 | import { getStreamStatus, setStreamStatus } from "../common/ts/streamStatus"; 10 | import type { PageState } from "../page/types"; 11 | import store from "../store"; 12 | import type { State } from "../store/types"; 13 | import { MessageType } from "../types"; 14 | 15 | const logger = new Logger("Content"); 16 | const performanceNavigationEntry = 17 | performance.getEntriesByType("navigation")[0]; 18 | if (performanceNavigationEntry) { 19 | logger.log( 20 | `Content script running (injected after ${( 21 | performance.now() - performanceNavigationEntry.startTime 22 | ).toFixed(0)}ms since navigation start).` 23 | ); 24 | } else { 25 | logger.log("Content script running."); 26 | } 27 | 28 | const broadcastChannelName = `TLP_${generateRandomString(32)}`; 29 | const broadcastChannel = new BroadcastChannel(broadcastChannelName); 30 | 31 | if (store.readyState === "complete") onStoreLoad(); 32 | else store.addEventListener("load", onStoreLoad); 33 | store.addEventListener("change", onStoreChange); 34 | 35 | browser.runtime.onMessage.addListener(onBackgroundMessage); 36 | broadcastChannel.addEventListener("message", onPageMessage); 37 | 38 | // Pass parameters to the page script. 39 | document.documentElement.dataset.tlpParams = JSON.stringify({ 40 | isChromium, 41 | workerScriptURL, // src/page/worker.ts 42 | broadcastChannelName, 43 | }); 44 | 45 | function onStoreLoad() { 46 | // Send store state to page script and worker script(s). 47 | const state = JSON.parse(JSON.stringify(store.state)); 48 | broadcastChannel.postMessage({ 49 | type: MessageType.PageScriptMessage, 50 | message: { 51 | type: MessageType.GetStoreStateResponse, 52 | state, 53 | }, 54 | }); 55 | broadcastChannel.postMessage({ 56 | type: MessageType.WorkerScriptMessage, 57 | message: { 58 | type: MessageType.GetStoreStateResponse, 59 | state, 60 | }, 61 | }); 62 | // Clear stats for stream on page load/reload. 63 | const channelName = findChannelFromTwitchTvUrl(location.href); 64 | clearStats(channelName); 65 | } 66 | 67 | /** 68 | * Clear stats for stream on page load/reload. 69 | * @param channelName 70 | * @param delayMs 71 | * @returns 72 | */ 73 | async function clearStats(channelName: string | null, delayMs?: number) { 74 | if (!channelName) return; 75 | if (delayMs) await new Promise(resolve => setTimeout(resolve, delayMs)); 76 | const channelNameLower = channelName.toLowerCase(); 77 | if (store.state.streamStatuses.hasOwnProperty(channelNameLower)) { 78 | delete store.state.streamStatuses[channelNameLower]; 79 | } 80 | logger.log(`Cleared stats for channel '${channelNameLower}'.`); 81 | } 82 | 83 | function onStoreChange(changes: Record) { 84 | const changedKeys = Object.keys(changes) as (keyof State)[]; 85 | // This is mainly to reduce the amount of messages sent to the page script. 86 | // (Also to reduce the number of console logs.) 87 | const ignoredKeys: (keyof State)[] = [ 88 | "adLog", 89 | "dnsResponses", 90 | "openedTwitchTabs", 91 | "streamStatuses", 92 | "videoWeaverUrlsByChannel", 93 | ]; 94 | if (changedKeys.every(key => ignoredKeys.includes(key))) return; 95 | logger.log("Store changed:", changes); 96 | broadcastChannel.postMessage({ 97 | type: MessageType.PageScriptMessage, 98 | message: { 99 | type: MessageType.GetStoreStateResponse, 100 | state: JSON.parse(JSON.stringify(store.state)), 101 | }, 102 | }); 103 | } 104 | 105 | function onBackgroundMessage(message: any): undefined { 106 | if (!message || !message.type) return; 107 | 108 | if ( 109 | [ 110 | MessageType.EnableFullModeResponse, 111 | MessageType.DisableFullModeResponse, 112 | ].includes(message.type) 113 | ) { 114 | // Forward to page script and worker script(s). 115 | broadcastChannel.postMessage({ 116 | type: MessageType.PageScriptMessage, 117 | message, 118 | }); 119 | broadcastChannel.postMessage({ 120 | type: MessageType.WorkerScriptMessage, 121 | message, 122 | }); 123 | } 124 | } 125 | 126 | async function onPageMessage(event: MessageEvent) { 127 | if (!event.data || event.data.type !== MessageType.ContentScriptMessage) { 128 | return; 129 | } 130 | 131 | const { message } = event.data; 132 | if (!message) return; 133 | 134 | if ( 135 | [ 136 | MessageType.EnableFullMode, 137 | MessageType.DisableFullMode, 138 | MessageType.UsherResponse, 139 | ].includes(message.type) 140 | ) { 141 | // Forward to background script. 142 | try { 143 | browser.runtime.sendMessage(message); 144 | } catch (error) { 145 | logger.error(`Failed to send '${message.type}' message:`, error); 146 | } 147 | return; 148 | } 149 | 150 | if (store.readyState !== "complete") { 151 | // Wait for the store to be loaded. 152 | await new Promise(resolve => { 153 | const listener = () => { 154 | store.removeEventListener("load", listener); 155 | resolve(); 156 | }; 157 | store.addEventListener("load", listener); 158 | }); 159 | } 160 | 161 | if (message.type === MessageType.GetStoreState) { 162 | const state = JSON.parse(JSON.stringify(store.state)); 163 | const from: PageState["scope"] | undefined = message.from; 164 | if (from !== "worker") { 165 | broadcastChannel.postMessage({ 166 | type: MessageType.PageScriptMessage, 167 | message: { 168 | type: MessageType.GetStoreStateResponse, 169 | state, 170 | }, 171 | }); 172 | } 173 | if (from !== "page") { 174 | broadcastChannel.postMessage({ 175 | type: MessageType.WorkerScriptMessage, 176 | message: { 177 | type: MessageType.GetStoreStateResponse, 178 | state, 179 | }, 180 | }); 181 | } 182 | } 183 | // --- 184 | else if (message.type === MessageType.ChannelSubStatusChange) { 185 | const { channelNameLower, wasSubscribed, isSubscribed } = message; 186 | const isWhitelisted = isChannelWhitelisted(channelNameLower); 187 | logger.log("Channel subscription status changed:", { 188 | channelNameLower, 189 | wasSubscribed, 190 | isSubscribed, 191 | isWhitelisted, 192 | }); 193 | const currentChannelNameLower = findChannelFromTwitchTvUrl( 194 | location.href 195 | )?.toLowerCase(); 196 | if (store.state.whitelistChannelSubscriptions && channelNameLower != null) { 197 | if (!wasSubscribed && isSubscribed) { 198 | store.state.activeChannelSubscriptions.push(channelNameLower); 199 | // Add to whitelist. 200 | if (!isWhitelisted) { 201 | store.state.whitelistedChannels.push(channelNameLower); 202 | logger.log(`Added '${channelNameLower}' to whitelist.`); 203 | if (channelNameLower === currentChannelNameLower) { 204 | location.reload(); 205 | } 206 | } 207 | } else if (wasSubscribed && !isSubscribed) { 208 | store.state.activeChannelSubscriptions = 209 | store.state.activeChannelSubscriptions.filter( 210 | channel => channel.toLowerCase() !== channelNameLower 211 | ); 212 | // Remove from whitelist. 213 | if (isWhitelisted) { 214 | store.state.whitelistedChannels = 215 | store.state.whitelistedChannels.filter( 216 | channel => channel.toLowerCase() !== channelNameLower 217 | ); 218 | logger.log(`Removed '${channelNameLower}' from whitelist.`); 219 | if (channelNameLower === currentChannelNameLower) { 220 | location.reload(); 221 | } 222 | } 223 | } 224 | } 225 | } 226 | // --- 227 | else if (message.type === MessageType.UpdateAdLog) { 228 | const isDuplicate = store.state.adLog.some(entry => { 229 | if (entry.channelName !== message.channelName) return false; 230 | if (message.timestamp - entry.timestamp >= 60000) { 231 | return false; // Entry is too old to be a duplicate (more than 1 minute). 232 | } 233 | if (entry.parsedLine != null && message.parsedLine != null) { 234 | if ( 235 | entry.parsedLine.adCommercialId != null && 236 | message.parsedLine.adCommercialId != null 237 | ) { 238 | return ( 239 | entry.parsedLine.adCommercialId === 240 | message.parsedLine.adCommercialId 241 | ); 242 | } 243 | return ( 244 | entry.parsedLine.adLineItemId === message.parsedLine.adLineItemId 245 | ); 246 | } 247 | return entry.videoWeaverUrl === message.videoWeaverUrl; 248 | }); 249 | if (isDuplicate) return; 250 | store.state.adLog.push({ 251 | timestamp: message.timestamp, 252 | channelName: message.channelName, 253 | videoWeaverUrl: message.videoWeaverUrl, 254 | rawLine: message.rawLine, 255 | parsedLine: message.parsedLine, 256 | }); 257 | await resolveAdIdentity(store.state.adLog.length - 1, 3000); 258 | logger.log( 259 | `Ad log updated (${store.state.adLog.length} entries):`, 260 | store.state.adLog[store.state.adLog.length - 1] 261 | ); 262 | } 263 | // --- 264 | else if (message.type === MessageType.ClearStats) { 265 | clearStats(message.channelName, 2000); 266 | } 267 | // --- 268 | else if (message.type === MessageType.ExtensionError) { 269 | const channelName = findChannelFromTwitchTvUrl(location.href); 270 | if (!channelName) return; 271 | const streamStatus = getStreamStatus(channelName); 272 | setStreamStatus(channelName, { 273 | ...(streamStatus ?? { proxied: false }), 274 | reason: message.errorMessage, 275 | }); 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /src/popup/menu.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Popup - TTV LOL PRO 7 | 8 | 9 | 10 | 11 |
12 | 13 |
14 | 19 |
20 | 21 |
22 | 23 |
24 |
25 |
26 | 27 | 28 |
29 |

30 |

31 |
32 |
33 |
34 | 39 | 74 |
75 |
76 | 77 | 78 |
    79 |
  1. 80 | 81 |
    82 | 83 | 96 |
    97 |
    98 |

    Options

    99 |

    100 | Configure proxies, whitelist, and more. 101 |

    102 |
    103 |
    104 |
  2. 105 |
  3. 106 | 111 |
    112 | 113 | 126 |
    127 |
    128 |

    Donate (Extension)

    129 |

    130 | Support the extension's development. 131 |

    132 |
    133 |
    134 |
  4. 135 |
  5. 136 | 141 |
    142 | 143 | 156 |
    157 |
    158 |

    Donate (Proxies)

    159 |

    160 | Support zGato's perfprod.com proxies. 161 |

    162 |
    163 |
    164 |
  6. 165 |
  7. 166 | 171 |
    172 | 173 | 186 |
    187 |
    188 |

    Discord

    189 |

    190 | Official TTV LOL PRO Discord server. 191 |

    192 |
    193 |
    194 |
  8. 195 |
196 | 197 | 198 |
    199 |
  1. 200 | 201 | 225 |
  2. 226 |
  3. 227 | 252 |
  4. 253 |
254 |
255 | 256 |
257 | 258 | Any questions? Please read the 259 | wiki 260 | first. 261 | 262 |
263 | 264 | 265 | 266 | 267 | -------------------------------------------------------------------------------- /src/options/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --body-background-color: #0e0f11; 3 | --container-background-color: #151619; 4 | --container-box-shadow-color: #0c0c0e; 5 | --container-width: 1100px; 6 | 7 | --font-primary: 8 | "Inter", "Roobert", "Helvetica Neue", Helvetica, Arial, sans-serif; 9 | --logo-size: 2.5rem; 10 | 11 | --brand-color: #aa51b8; 12 | --text-primary: #e4e6e7; 13 | --text-secondary: #8d9296; 14 | --link: #be68ce; 15 | --link-hover: #cc88d8; 16 | 17 | --input-background-color: #1d1f23; 18 | --input-background-color-disabled: #2e3138; 19 | --input-border-color: #353840; 20 | --input-text-primary: #c3c4ca; 21 | --input-text-secondary: #7a8085; 22 | --input-max-width: 450px; 23 | 24 | --button-background-color: #353840; 25 | --button-background-color-hover: #464953; 26 | --button-text-primary: #c3c4ca; 27 | 28 | --switch-width: 32px; 29 | --switch-height: 18px; 30 | --switch-padding: 2px; 31 | 32 | --low-color: #06c157; 33 | --low-bg-color: #1e2421; 34 | --medium-color: #f9c643; 35 | --medium-bg-color: #24221e; 36 | --high-color: #f93e3e; 37 | --high-bg-color: #241e1e; 38 | } 39 | 40 | ::-moz-selection, 41 | ::selection { 42 | background-color: var(--brand-color); 43 | color: #ffffff; 44 | } 45 | 46 | body { 47 | margin: 0; 48 | background-image: url("../common/images/options_bg.png"); 49 | background-repeat: repeat; 50 | background-color: var(--body-background-color); 51 | color: var(--text-primary); 52 | accent-color: var(--brand-color); 53 | font-size: 100%; 54 | font-family: var(--font-primary); 55 | } 56 | body > .container:first-child { 57 | z-index: 0; 58 | width: min(100%, var(--container-width)); 59 | margin: 0 auto; 60 | background-color: var(--container-background-color); 61 | box-shadow: 0 0 32px var(--container-box-shadow-color); 62 | } 63 | 64 | header { 65 | display: flex; 66 | flex-direction: row; 67 | align-items: center; 68 | justify-content: space-between; 69 | padding: 1.5rem 1.5rem 0 1.5rem; 70 | gap: 1rem; 71 | background-color: var(--container-background-color); 72 | } 73 | header > .title-container { 74 | display: flex; 75 | flex-direction: row; 76 | align-items: center; 77 | justify-content: flex-start; 78 | gap: 1rem; 79 | } 80 | header > .title-container > .logo { 81 | width: var(--logo-size); 82 | height: var(--logo-size); 83 | } 84 | header > .title-container > .title { 85 | margin: 0; 86 | font-size: 1.75rem; 87 | } 88 | header > #buttons-container { 89 | display: flex; 90 | flex-direction: row; 91 | align-items: center; 92 | gap: 0.5rem; 93 | } 94 | 95 | main { 96 | display: none; 97 | padding: 0 2rem 2rem 2rem; 98 | } 99 | 100 | footer { 101 | display: flex; 102 | align-items: center; 103 | justify-content: space-between; 104 | padding: 1.5rem; 105 | border-top: 1px solid var(--input-border-color); 106 | font-size: 9pt; 107 | } 108 | footer > nav > ul { 109 | display: flex; 110 | flex-direction: row; 111 | align-items: center; 112 | justify-content: flex-start; 113 | margin: 0; 114 | padding: 0; 115 | gap: 1.5rem; 116 | list-style-type: none; 117 | } 118 | 119 | a, 120 | a:visited { 121 | color: var(--link); 122 | transition: color 100ms ease-in-out; 123 | } 124 | a:hover, 125 | a:visited:hover { 126 | color: var(--link-hover); 127 | } 128 | 129 | input[type="text"], 130 | select { 131 | height: 30px; 132 | padding: 0 0.65rem; 133 | border: 1px solid var(--input-border-color); 134 | border-radius: 6px; 135 | background-color: var(--input-background-color); 136 | color: var(--input-text-primary); 137 | vertical-align: middle; 138 | } 139 | input[type="text"]:disabled { 140 | background-color: var(--input-background-color-disabled); 141 | color: var(--input-text-secondary); 142 | } 143 | input[type="text"]::placeholder { 144 | font-style: italic; 145 | } 146 | 147 | input[type="button"], 148 | input[type="submit"], 149 | button { 150 | margin: 0.125rem 0; 151 | padding: 0.5rem 1rem; 152 | border: 0; 153 | border-radius: 6px; 154 | background-color: var(--button-background-color); 155 | color: var(--button-text-primary); 156 | cursor: pointer; 157 | transition: background-color 100ms ease-in-out; 158 | } 159 | input[type="button"]:hover:enabled, 160 | input[type="submit"]:hover:enabled, 161 | button:hover:enabled { 162 | background-color: var(--button-background-color-hover); 163 | } 164 | input[type="button"]:disabled, 165 | input[type="submit"]:disabled, 166 | button:disabled { 167 | cursor: not-allowed; 168 | opacity: 0.5; 169 | } 170 | .button-primary { 171 | background-color: var(--brand-color); 172 | color: #ffffff; 173 | } 174 | .button-primary:hover:enabled { 175 | background-color: var(--link-hover); 176 | } 177 | 178 | input[type="checkbox"] { 179 | appearance: none; 180 | position: relative; 181 | top: -0.1em; 182 | width: var(--switch-width); 183 | height: var(--switch-height); 184 | margin: 2px 4px 2px 0; 185 | padding: 0; 186 | border-radius: var(--switch-height); 187 | outline: none; 188 | background-color: var(--button-background-color); 189 | vertical-align: middle; 190 | cursor: pointer; 191 | transition: background 100ms ease-in-out; 192 | } 193 | input[type="checkbox"]::before { 194 | position: absolute; 195 | top: var(--switch-padding); 196 | left: var(--switch-padding); 197 | width: calc(var(--switch-height) - 2 * var(--switch-padding)); 198 | height: calc(var(--switch-height) - 2 * var(--switch-padding)); 199 | border-radius: 50%; 200 | background-color: white; 201 | content: ""; 202 | transition: transform 100ms ease-in-out; 203 | } 204 | input[type="checkbox"]:checked { 205 | background-color: var(--brand-color); 206 | } 207 | input[type="checkbox"]:checked::before { 208 | transform: translateX(calc(var(--switch-width) - var(--switch-height))); 209 | } 210 | input[type="checkbox"]:focus-visible { 211 | outline: 2px solid white; 212 | } 213 | input[type="checkbox"]:disabled { 214 | cursor: not-allowed; 215 | opacity: 0.5; 216 | } 217 | input[type="checkbox"]:disabled + label { 218 | opacity: 0.5; 219 | } 220 | 221 | fieldset { 222 | border: 0; 223 | } 224 | 225 | small { 226 | color: var(--text-secondary); 227 | font-size: 9pt; 228 | } 229 | 230 | hr { 231 | margin: 2.5rem 0; 232 | border: 0; 233 | border-top: 1px solid var(--input-border-color); 234 | } 235 | 236 | .section { 237 | margin: 0 0 3rem 0; 238 | } 239 | .section:last-child { 240 | margin-bottom: 0; 241 | } 242 | .section h2 { 243 | margin-top: 0; 244 | margin-bottom: 0.25rem; 245 | font-size: 1.3rem; 246 | } 247 | 248 | .tag { 249 | margin-left: 0.25rem; 250 | padding: 0.25rem 0.5rem; 251 | border-radius: 4px; 252 | background-color: var(--input-border-color); 253 | color: var(--input-text-primary); 254 | font-weight: 600; 255 | font-size: 0.65rem; 256 | text-transform: uppercase; 257 | } 258 | 259 | li.hide-marker::marker { 260 | display: none; 261 | content: ""; 262 | } 263 | 264 | input[type="radio"]:not(:checked) ~ .store-list { 265 | opacity: 0.5; 266 | } 267 | .store-list > li { 268 | position: relative; 269 | max-width: var(--input-max-width); 270 | margin-bottom: 0.25rem; 271 | } 272 | .store-list > li > input { 273 | width: 100%; 274 | } 275 | .store-list > li:hover > input, 276 | .store-list > li:focus-within > input { 277 | padding-right: 2.75rem; 278 | } 279 | .store-list > li > .move-buttons-container { 280 | display: none; 281 | z-index: 1; 282 | position: absolute; 283 | top: 50%; 284 | right: 0.25rem; 285 | flex-direction: row; 286 | padding: 0.25rem; 287 | gap: 0.25rem; 288 | transform: translateY(-50%); 289 | background-color: var(--input-background-color); 290 | } 291 | .store-list > li:hover > .move-buttons-container, 292 | .store-list > li:focus-within > .move-buttons-container { 293 | display: flex; 294 | } 295 | .store-list > li > .move-buttons-container > button { 296 | margin: 0; 297 | padding: 0; 298 | border-radius: 100vmax; 299 | background-color: unset; 300 | color: var(--input-text-secondary); 301 | font-size: 11pt; 302 | cursor: pointer; 303 | transition: color 100ms ease-in-out; 304 | } 305 | .store-list > li > .move-buttons-container > button:hover:enabled, 306 | .store-list > li > .move-buttons-container > button:focus { 307 | color: var(--input-text-primary); 308 | } 309 | 310 | .options-list { 311 | margin-bottom: 0; 312 | list-style-type: none; 313 | } 314 | .options-list > li { 315 | position: relative; 316 | margin-bottom: 1rem; 317 | } 318 | .options-list > li > input[type="checkbox"] { 319 | position: absolute; 320 | left: calc(calc(var(--switch-width) + 8px) * -1); 321 | } 322 | 323 | .experience-container { 324 | position: relative; 325 | } 326 | .experience-container .separator { 327 | display: block; 328 | position: absolute; 329 | top: 50%; 330 | left: 0; 331 | width: 100%; 332 | height: 1px; 333 | background-color: var(--input-border-color); 334 | } 335 | .experience-container .segmented-control { 336 | display: flex; 337 | z-index: 1; 338 | position: relative; 339 | top: 0; 340 | left: 50%; 341 | align-items: center; 342 | justify-content: center; 343 | width: fit-content; 344 | margin: 2rem 0; 345 | padding: 0; 346 | overflow: hidden; 347 | transform: translateX(-50%); 348 | border: 1px solid var(--input-border-color); 349 | border-radius: 100vmax; 350 | background-color: var(--input-background-color); 351 | } 352 | .experience-container .segmented-control:has(.segment input:focus-visible) { 353 | border: 1px solid white; 354 | outline: 1px solid white; 355 | } 356 | .experience-container .segmented-control .segment { 357 | position: relative; 358 | overflow: hidden; 359 | border-radius: 100vmax; 360 | text-align: center; 361 | } 362 | .experience-container .segmented-control .segment input { 363 | appearance: none; 364 | position: absolute; 365 | top: -100vmax; 366 | } 367 | .experience-container .segmented-control .segment label { 368 | display: block; 369 | margin: 0; 370 | padding: 0.75rem 1.5rem; 371 | font-size: 0.9rem; 372 | cursor: pointer; 373 | transition: box-shadow 100ms ease-in-out; 374 | } 375 | .experience-container .segmented-control .segment input:checked + label { 376 | background-color: var(--brand-color); 377 | color: #ffffff; 378 | } 379 | .experience-container .segmented-control .segment label:hover { 380 | box-shadow: inset 0 0 100vmax 100vmax rgba(255, 255, 255, 0.075); 381 | } 382 | 383 | #passport-level-container { 384 | display: grid; 385 | grid-template-rows: auto auto auto; 386 | grid-template-columns: auto 1fr; 387 | grid-template-areas: 388 | "image slider" 389 | ". usage" 390 | ". warning"; 391 | column-gap: 1.25rem; 392 | row-gap: 0; 393 | align-items: center; 394 | margin: 1rem 0 1.5rem 0; 395 | } 396 | 397 | #passport-level-container .grid-image { 398 | grid-area: image; 399 | } 400 | 401 | #passport-level-image { 402 | height: 55px; 403 | } 404 | 405 | #passport-level-container .grid-slider { 406 | grid-area: slider; 407 | max-width: var(--input-max-width); 408 | } 409 | #passport-level-container .grid-slider.expert-mode > fieldset { 410 | margin: 0; 411 | padding: 0; 412 | } 413 | 414 | #passport-level-slider { 415 | width: 100%; 416 | max-width: var(--input-max-width); 417 | margin: 0; 418 | } 419 | 420 | #passport-level-slider-datalist { 421 | display: grid; 422 | grid-template-columns: repeat(3, minmax(0, 1fr)); 423 | width: 100%; 424 | max-width: var(--input-max-width); 425 | text-align: center; 426 | } 427 | #passport-level-slider-datalist > option:first-child { 428 | text-align: left; 429 | } 430 | #passport-level-slider-datalist > option:last-child { 431 | text-align: right; 432 | } 433 | 434 | #passport-level-container .grid-usage { 435 | grid-area: usage; 436 | max-width: var(--input-max-width); 437 | } 438 | #passport-level-proxy-usage { 439 | width: 100%; 440 | max-width: var(--input-max-width); 441 | margin-top: 0.5rem; 442 | padding: 0; 443 | overflow: hidden; 444 | border: 1px solid var(--input-border-color); 445 | border-radius: 18px; 446 | background-color: var(--input-background-color); 447 | } 448 | #passport-level-proxy-usage:has( 449 | #passport-level-proxy-usage-summary:focus-visible 450 | ) { 451 | outline-style: solid; 452 | outline-width: 1px; 453 | } 454 | #passport-level-proxy-usage[data-usage="low"] { 455 | border-color: var(--low-color); 456 | outline-color: var(--low-color); 457 | background-color: var(--low-bg-color); 458 | } 459 | #passport-level-proxy-usage[data-usage="medium"] { 460 | border-color: var(--medium-color); 461 | outline-color: var(--medium-color); 462 | background-color: var(--medium-bg-color); 463 | } 464 | #passport-level-proxy-usage[data-usage="high"] { 465 | border-color: var(--high-color); 466 | outline-color: var(--high-color); 467 | background-color: var(--high-bg-color); 468 | } 469 | 470 | #passport-level-proxy-usage-summary { 471 | margin: 0; 472 | padding: 0.5rem; 473 | font-size: 0.9rem; 474 | cursor: pointer; 475 | transition: 476 | background-color 100ms ease-in-out, 477 | color 100ms ease-in-out; 478 | } 479 | #passport-level-proxy-usage-summary::marker { 480 | content: none; 481 | } 482 | #passport-level-proxy-usage-summary::after { 483 | display: block; 484 | float: right; 485 | transform: translateY(-15%) rotate(-45deg); 486 | content: "∟"; 487 | text-align: right; 488 | } 489 | #passport-level-proxy-usage[open] #passport-level-proxy-usage-summary::after { 490 | display: block; 491 | float: right; 492 | transform: translateY(30%) rotate(135deg); 493 | content: "∟"; 494 | text-align: right; 495 | } 496 | #passport-level-proxy-usage[data-usage="low"] 497 | #passport-level-proxy-usage-summary:hover { 498 | background-color: var(--low-color); 499 | color: #000000; 500 | } 501 | #passport-level-proxy-usage[data-usage="medium"] 502 | #passport-level-proxy-usage-summary:hover { 503 | background-color: var(--medium-color); 504 | color: #000000; 505 | } 506 | #passport-level-proxy-usage[data-usage="high"] 507 | #passport-level-proxy-usage-summary:hover { 508 | background-color: var(--high-color); 509 | color: #000000; 510 | } 511 | 512 | #passport-level-proxy-usage-table { 513 | width: 100%; 514 | margin: 0; 515 | padding: 0.5rem; 516 | font-size: 0.7rem; 517 | } 518 | #passport-level-proxy-usage-table > tbody > tr > td:nth-child(2) { 519 | color: var(--text-secondary); 520 | text-align: right; 521 | } 522 | 523 | #passport-level-warning { 524 | display: none; 525 | grid-area: warning; 526 | margin-top: 0.75rem; 527 | } 528 | 529 | .proxy-mode-radio-buttons { 530 | margin-top: 1rem; 531 | } 532 | 533 | @media screen and (max-width: 800px) { 534 | header { 535 | flex-direction: column; 536 | } 537 | } 538 | 539 | @media screen and (max-width: 600px) { 540 | main { 541 | padding: 0 1.25rem 1.25rem 1.25rem; 542 | } 543 | 544 | header > #buttons-container { 545 | flex-direction: column; 546 | width: 100%; 547 | max-width: 400px; 548 | gap: 0.25rem; 549 | } 550 | header > #buttons-container > button { 551 | width: 100%; 552 | } 553 | 554 | footer > nav > ul { 555 | gap: 0.5rem; 556 | } 557 | } 558 | --------------------------------------------------------------------------------