🔖 Release version 2.3.0

This commit is contained in:
Younes Aassila 2024-01-28 14:37:07 +01:00 committed by GitHub
commit 25c138edbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 3781 additions and 1380 deletions

View File

@ -1,5 +1,5 @@
<h1 align="center">
<img alt="Icon" src="src/images/brand/icon.png" height="100" width="100" />
<img alt="Icon" src="src/common/images/brand/icon.png" height="100" width="100" />
<br />
TTV LOL PRO
<br />
@ -48,14 +48,14 @@
>
<img
alt="Chrome Web Store"
src="src/images/badges/chrome_web_store.png"
src="src/common/images/badges/chrome_web_store.png"
height="50"
/>
</a>
<a href="https://addons.mozilla.org/addon/ttv-lol-pro/">
<img
alt="Firefox Add-ons"
src="src/images/badges/firefox_addons.png"
src="src/common/images/badges/firefox_addons.png"
height="50"
/>
</a>
@ -65,25 +65,19 @@
> Looking for TTV LOL PRO v1? [Click here](https://github.com/younesaassila/ttv-lol-pro/tree/v1).
TTV LOL PRO removes _most_ livestream ads from Twitch. This is free, don't expect it to be perfect. Issues? Complain to Twitch
TTV LOL PRO removes most livestream ads from Twitch. This is free, don't expect it to be perfect.
**TTV LOL PRO:**
TTV LOL PRO is a fork of TTV LOL that:
- removes _most_ livestream ads from Twitch,
- uses an improved ad blocking method,
- uses standard HTTP proxies (thus improving proxy compatibility and your privacy),
- adds a stream status widget to the popup,
- lets you whitelist channels,
- improves TTV LOL's popup by showing stream status,
- lets you add custom primary/fallback proxies.
- lets you use your own proxies.
**Recommended:**
TTV LOL PRO does not remove banner ads, nor does it remove ads from VODs. For the best experience, we recommend using [uBlock Origin](https://ublockorigin.com/) alongside TTV LOL PRO.
- [uBlock Origin](https://ublockorigin.com/)
- removes banner ads,
- removes ads on VODs.
**Frequently Asked Questions (FAQ):**
- [Click here](FAQ.md)
**Any questions? Please read the [FAQ](FAQ.md).**
## Screenshots

1323
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "ttv-lol-pro",
"version": "2.2.3",
"version": "2.3.0",
"description": "TTV LOL PRO removes most livestream ads from Twitch.",
"@parcel/bundler-default": {
"minBundles": 10000000,
@ -36,24 +36,25 @@
"web-extension",
"adblocker"
],
"author": "TTV-LOL (https://github.com/TTV-LOL)",
"author": "Younes Aassila (https://github.com/younesaassila)",
"contributors": [
"Younes Aassila (https://github.com/younesaassila)"
"Marc Gómez (https://github.com/zGato)"
],
"license": "GPL-3.0",
"dependencies": {
"bowser": "^2.11.0",
"ip": "^1.1.8"
"ip": "^1.1.8",
"m3u8-parser": "^7.1.0"
},
"devDependencies": {
"@parcel/config-webextension": "^2.10.3",
"@types/chrome": "^0.0.254",
"@parcel/config-webextension": "^2.11.0",
"@types/chrome": "^0.0.259",
"@types/ip": "^1.1.3",
"@types/webextension-polyfill": "^0.10.7",
"buffer": "^6.0.3",
"os-browserify": "^0.3.0",
"parcel": "^2.10.3",
"postcss": "^8.4.32",
"parcel": "^2.11.0",
"postcss": "^8.4.33",
"prettier": "2.8.8",
"prettier-plugin-css-order": "^1.3.1",
"prettier-plugin-organize-imports": "^3.2.4",

View File

@ -3,9 +3,11 @@ import isChromium from "../common/ts/isChromium";
import checkForOpenedTwitchTabs from "./handlers/checkForOpenedTwitchTabs";
import onAuthRequired from "./handlers/onAuthRequired";
import onBeforeSendHeaders from "./handlers/onBeforeSendHeaders";
import onBeforeTwitchTvSendHeaders from "./handlers/onBeforeTwitchTvSendHeaders";
import onBeforeVideoWeaverRequest from "./handlers/onBeforeVideoWeaverRequest";
import onContentScriptMessage from "./handlers/onContentScriptMessage";
import onInstalledStoreCleanup from "./handlers/onInstalledStoreCleanup";
import onProxyRequest from "./handlers/onProxyRequest";
import onProxySettingsChange from "./handlers/onProxySettingsChanged";
import onResponseStarted from "./handlers/onResponseStarted";
import onStartupStoreCleanup from "./handlers/onStartupStoreCleanup";
import onTabCreated from "./handlers/onTabCreated";
@ -15,7 +17,10 @@ import onTabUpdated from "./handlers/onTabUpdated";
console.info("🚀 Background script loaded.");
// Cleanup the session-related data in the store on startup.
// Cleanup old data in the store on update.
browser.runtime.onInstalled.addListener(onInstalledStoreCleanup);
// Cleanup session data in the store on startup.
browser.runtime.onStartup.addListener(onStartupStoreCleanup);
// Handle proxy authentication.
@ -31,8 +36,8 @@ browser.webRequest.onResponseStarted.addListener(onResponseStarted, {
});
if (isChromium) {
// Listen to whether proxy is set or not.
browser.proxy.settings.onChange.addListener(onProxySettingsChange);
// Listen to messages from the content script.
browser.runtime.onMessage.addListener(onContentScriptMessage);
// Check if there are any opened Twitch tabs on startup.
checkForOpenedTwitchTabs();
@ -43,15 +48,21 @@ if (isChromium) {
browser.tabs.onRemoved.addListener(onTabRemoved);
browser.tabs.onReplaced.addListener(onTabReplaced);
} else {
// Inject page script.
browser.webRequest.onBeforeSendHeaders.addListener(
onBeforeTwitchTvSendHeaders,
{
urls: ["https://www.twitch.tv/*", "https://m.twitch.tv/*"],
types: ["main_frame"],
},
["blocking", "requestHeaders"]
);
// Block tracking pixels.
browser.webRequest.onBeforeRequest.addListener(
() => ({ cancel: true }),
{
urls: [
"https://*.twitch.tv/r/s/*",
"https://*.twitch.tv/r/c/*",
"https://*.ads.twitch.tv/*",
],
urls: ["https://*.twitch.tv/r/s/*", "https://*.twitch.tv/r/c/*"],
},
["blocking"]
);

View File

@ -1,5 +1,5 @@
import { WebRequest } from "webextension-polyfill";
import getProxyInfoFromUrl from "../../common/ts/getProxyInfoFromUrl";
import { getProxyInfoFromUrl } from "../../common/ts/proxyInfo";
import store from "../../store";
const pendingRequests: string[] = [];

View File

@ -0,0 +1,44 @@
import pageScriptURL from "url:../../page/page.ts";
import workerScriptURL from "url:../../page/worker.ts";
import { WebRequest } from "webextension-polyfill";
import filterResponseDataWrapper from "../../common/ts/filterResponseDataWrapper";
import getHostFromUrl from "../../common/ts/getHostFromUrl";
import isChromium from "../../common/ts/isChromium";
import { twitchTvHostRegex } from "../../common/ts/regexes";
export default function onBeforeTwitchTvSendHeaders(
details: WebRequest.OnBeforeSendHeadersDetailsType
): void | WebRequest.BlockingResponseOrPromise {
const host = getHostFromUrl(details.url);
if (!host || !twitchTvHostRegex.test(host)) return;
// Ignore requests for non-HTML resources.
const acceptHeader = details.requestHeaders?.find(
header => header.name.toLowerCase() === "accept"
);
if (!acceptHeader || !acceptHeader.value) return;
if (!acceptHeader.value.toLowerCase().includes("text/html")) return;
filterResponseDataWrapper(details, text => {
const parser = new DOMParser();
const document = parser.parseFromString(text, "text/html");
const script = document.createElement("script");
script.src = pageScriptURL; // src/page/page.ts
script.dataset.params = JSON.stringify({
isChromium,
workerScriptURL, // src/page/worker.ts
});
script.onload = () => script.remove();
// ---------------------------------------
// 🦊 Attention Firefox Addon Reviewer 🦊
// ---------------------------------------
// Please note that this does NOT involve remote code execution. The injected scripts are bundled
// with the extension. The `url:` imports above are used to get the runtime URLs of the respective scripts.
// Additionally, there is no custom Content Security Policy (CSP) in use.
(document.head || document.documentElement).prepend(script);
return (
(document.compatMode === "BackCompat" ? "" : "<!DOCTYPE html>") +
document.documentElement.outerHTML
);
});
}

View File

@ -3,6 +3,7 @@ import filterResponseDataWrapper from "../../common/ts/filterResponseDataWrapper
import findChannelFromTwitchTvUrl from "../../common/ts/findChannelFromTwitchTvUrl";
import findChannelFromVideoWeaverUrl from "../../common/ts/findChannelFromVideoWeaverUrl";
import getHostFromUrl from "../../common/ts/getHostFromUrl";
import { getUrlFromProxyInfo } from "../../common/ts/proxyInfo";
import { videoWeaverHostRegex } from "../../common/ts/regexes";
import store from "../../store";
import { AdType, ProxyInfo } from "../../types";
@ -41,27 +42,20 @@ export default function onBeforeVideoWeaverRequest(
);
const proxy =
details.proxyInfo && details.proxyInfo.type !== "direct"
? `${details.proxyInfo.host}:${details.proxyInfo.port}`
? getUrlFromProxyInfo(details.proxyInfo)
: null;
const adLog = store.state.adLog.filter(
entry => details.timeStamp - entry.timestamp < 1000 * 60 * 60 * 24 * 7 // 7 days
);
store.state.adLog = [
...adLog,
{
adType: isMidroll ? AdType.MIDROLL : AdType.PREROLL,
channel: channelName,
isPurpleScreen,
proxy,
proxyTwitchWebpage: store.state.proxyTwitchWebpage,
proxyUsherRequests: store.state.proxyUsherRequests,
anonymousMode: store.state.anonymousMode,
timestamp: details.timeStamp,
videoWeaverHost: host,
videoWeaverUrl: details.url,
},
];
store.state.adLog.push({
adType: isMidroll ? AdType.MIDROLL : AdType.PREROLL,
isPurpleScreen,
proxy,
channel: channelName,
passportLevel: store.state.passportLevel,
anonymousMode: store.state.anonymousMode,
timestamp: details.timeStamp,
videoWeaverHost: host,
videoWeaverUrl: details.url,
});
console.log(`📝 Ad log updated (${store.state.adLog.length} entries).`);
console.log(text);

View File

@ -0,0 +1,75 @@
import browser, { Runtime } from "webextension-polyfill";
import { updateProxySettings } from "../../common/ts/proxySettings";
import store from "../../store";
import { MessageType, ProxyRequestType } from "../../types";
type Timeout = string | number | NodeJS.Timeout | undefined;
const timeoutMap: Map<ProxyRequestType, Timeout> = new Map();
export default function onContentScriptMessage(
message: any,
sender: Runtime.MessageSender,
sendResponse: () => void
): true | void | Promise<any> {
if (message.type === MessageType.EnableFullMode) {
if (!sender.tab?.id) return;
const requestType = message.requestType as ProxyRequestType;
// Clear existing timeout for request type.
if (timeoutMap.has(requestType)) {
clearTimeout(timeoutMap.get(requestType));
}
// Set new timeout for request type.
const fetchTimeoutMs = 3000; // Time for fetch to be called.
const replyTimeoutMs = Date.now() - message.timestamp; // Time for reply to be received.
timeoutMap.set(
requestType,
setTimeout(() => {
console.log(
`[TTV LOL PRO] Disabling full mode (request type: ${requestType}, timeout)`
);
timeoutMap.delete(requestType);
if (store.state.chromiumProxyActive) {
updateProxySettings([...timeoutMap.keys()]);
}
}, fetchTimeoutMs + replyTimeoutMs)
);
if (store.state.chromiumProxyActive) {
updateProxySettings([...timeoutMap.keys()]);
}
console.log(
`[TTV LOL PRO] Enabled full mode for ${
fetchTimeoutMs + replyTimeoutMs
}ms (request type: ${requestType})`
);
try {
browser.tabs.sendMessage(sender.tab.id, {
type: MessageType.EnableFullModeResponse,
});
} catch (error) {
console.error(
"[TTV LOL PRO] Failed to send EnableFullModeResponse message",
error
);
}
}
if (message.type === MessageType.DisableFullMode) {
const requestType = message.requestType as ProxyRequestType;
// Clear existing timeout for request type.
if (timeoutMap.has(requestType)) {
clearTimeout(timeoutMap.get(requestType));
timeoutMap.delete(requestType);
}
if (store.state.chromiumProxyActive) {
updateProxySettings([...timeoutMap.keys()]);
}
console.log(
`[TTV LOL PRO] Disabled full mode (request type: ${requestType})`
);
}
}

View File

@ -0,0 +1,41 @@
import { Runtime } from "webextension-polyfill";
import isChromium from "../../common/ts/isChromium";
import store from "../../store";
export default function onInstalledStoreCleanup(
details: Runtime.OnInstalledDetailsType
): void {
if (store.readyState !== "complete")
return store.addEventListener("load", () =>
onInstalledStoreCleanup(details)
);
if (details.reason === "update") {
// Remove old Chromium normal proxy.
const oldChromiumProxy = "chrome.api.cdn-perfprod.com:4023";
if (store.state.normalProxies.includes(oldChromiumProxy)) {
store.state.normalProxies = store.state.normalProxies.filter(
proxy => proxy !== oldChromiumProxy
);
if (store.state.normalProxies.length === 0) {
store.state.optimizedProxiesEnabled = true;
}
}
// Add new Chromium optimized proxy.
const newChromiumProxy = "chromium.api.cdn-perfprod.com:2023";
if (
isChromium &&
!store.state.optimizedProxies.includes(newChromiumProxy)
) {
// Remove Firefox optimized proxy (used during beta).
const firefoxProxy = "firefox.api.cdn-perfprod.com:2023";
if (store.state.optimizedProxies.includes(firefoxProxy)) {
store.state.optimizedProxies = store.state.optimizedProxies.filter(
proxy => proxy !== firefoxProxy
);
}
store.state.optimizedProxies.push(newChromiumProxy);
}
}
}

View File

@ -3,9 +3,10 @@ import findChannelFromTwitchTvUrl from "../../common/ts/findChannelFromTwitchTvU
import findChannelFromUsherUrl from "../../common/ts/findChannelFromUsherUrl";
import findChannelFromVideoWeaverUrl from "../../common/ts/findChannelFromVideoWeaverUrl";
import getHostFromUrl from "../../common/ts/getHostFromUrl";
import getProxyInfoFromUrl from "../../common/ts/getProxyInfoFromUrl";
import isChannelWhitelisted from "../../common/ts/isChannelWhitelisted";
import isFlaggedRequest from "../../common/ts/isFlaggedRequest";
import isRequestTypeProxied from "../../common/ts/isRequestTypeProxied";
import { getProxyInfoFromUrl } from "../../common/ts/proxyInfo";
import {
passportHostRegex,
twitchGqlHostRegex,
@ -14,20 +15,11 @@ import {
videoWeaverHostRegex,
} from "../../common/ts/regexes";
import store from "../../store";
import type { ProxyInfo } from "../../types";
import { ProxyInfo, ProxyRequestType } from "../../types";
export default async function onProxyRequest(
details: Proxy.OnRequestDetailsType
): Promise<ProxyInfo | ProxyInfo[]> {
const host = getHostFromUrl(details.url);
if (!host) return { type: "direct" };
const documentHost = details.documentUrl
? getHostFromUrl(details.documentUrl)
: null;
const isFromTwitchTvHost =
documentHost && twitchTvHostRegex.test(documentHost);
// Wait for the store to be ready.
if (store.readyState !== "complete") {
await new Promise(resolve => {
@ -39,37 +31,55 @@ export default async function onProxyRequest(
});
}
const isFlagged =
(store.state.optimizedProxiesEnabled &&
isFlaggedRequest(details.requestHeaders)) ||
!store.state.optimizedProxiesEnabled;
const host = getHostFromUrl(details.url);
if (!host) return { type: "direct" };
const documentHost = details.documentUrl
? getHostFromUrl(details.documentUrl)
: null;
// Twitch requests from non-Twitch hosts are not supported.
if (
documentHost != null && // Twitch webpage requests have no document URL.
!passportHostRegex.test(documentHost) && // Passport requests have a `passport.twitch.tv` document URL.
!twitchTvHostRegex.test(documentHost)
) {
return { type: "direct" };
}
const proxies = store.state.optimizedProxiesEnabled
? store.state.optimizedProxies
: store.state.normalProxies;
const proxyInfoArray = getProxyInfoArrayFromUrls(proxies);
// Twitch webpage requests.
if (store.state.proxyTwitchWebpage && twitchTvHostRegex.test(host)) {
console.log(`⌛ Proxying ${details.url} through one of: <empty>`);
return proxyInfoArray;
}
// Twitch GraphQL requests.
if (
store.state.proxyTwitchWebpage &&
twitchGqlHostRegex.test(host) &&
isFlagged
) {
console.log(
`⌛ Proxying ${details.url} through one of: ${
proxies.toString() || "<empty>"
}`
);
return proxyInfoArray;
}
const requestParams = {
isChromium: false,
optimizedProxiesEnabled: store.state.optimizedProxiesEnabled,
passportLevel: store.state.passportLevel,
isFlagged: isFlaggedRequest(details.requestHeaders),
};
const proxyPassportRequest = isRequestTypeProxied(
ProxyRequestType.Passport,
requestParams
);
const proxyUsherRequest = isRequestTypeProxied(
ProxyRequestType.Usher,
requestParams
);
const proxyVideoWeaverRequest = isRequestTypeProxied(
ProxyRequestType.VideoWeaver,
requestParams
);
const proxyGraphQLRequest = isRequestTypeProxied(
ProxyRequestType.GraphQL,
requestParams
);
const proxyTwitchWebpageRequest = isRequestTypeProxied(
ProxyRequestType.TwitchWebpage,
requestParams
);
// Passport requests.
if (store.state.proxyUsherRequests && passportHostRegex.test(host)) {
if (proxyPassportRequest && passportHostRegex.test(host)) {
console.log(
`⌛ Proxying ${details.url} through one of: ${
proxies.toString() || "<empty>"
@ -79,15 +89,11 @@ export default async function onProxyRequest(
}
// Usher requests.
if (store.state.proxyUsherRequests && usherHostRegex.test(host)) {
// Don't proxy Usher requests from non-supported hosts.
if (!isFromTwitchTvHost) {
console.log(
`✋ '${details.url}' from host '${documentHost}' is not supported.`
);
if (proxyUsherRequest && usherHostRegex.test(host)) {
if (details.url.includes("/vod/")) {
console.log(`✋ '${details.url}' is a VOD manifest.`);
return { type: "direct" };
}
// Don't proxy whitelisted channels.
const channelName = findChannelFromUsherUrl(details.url);
if (isChannelWhitelisted(channelName)) {
console.log(`✋ Channel '${channelName}' is whitelisted.`);
@ -102,15 +108,7 @@ export default async function onProxyRequest(
}
// Video Weaver requests.
if (videoWeaverHostRegex.test(host) && isFlagged) {
// Don't proxy Video Weaver requests from non-supported hosts.
if (!isFromTwitchTvHost) {
console.log(
`✋ '${details.url}' from host '${documentHost}' is not supported.`
);
return { type: "direct" };
}
// Don't proxy whitelisted channels.
if (proxyVideoWeaverRequest && videoWeaverHostRegex.test(host)) {
const channelName =
findChannelFromVideoWeaverUrl(details.url) ??
findChannelFromTwitchTvUrl(details.documentUrl);
@ -126,6 +124,26 @@ export default async function onProxyRequest(
return proxyInfoArray;
}
// Twitch GraphQL requests.
if (proxyGraphQLRequest && twitchGqlHostRegex.test(host)) {
console.log(
`⌛ Proxying ${details.url} through one of: ${
proxies.toString() || "<empty>"
}`
);
return proxyInfoArray;
}
// Twitch webpage requests.
if (proxyTwitchWebpageRequest && twitchTvHostRegex.test(host)) {
console.log(
`⌛ Proxying ${details.url} through one of: ${
proxies.toString() || "<empty>"
}`
);
return proxyInfoArray;
}
return { type: "direct" };
}

View File

@ -1,10 +0,0 @@
import { Types } from "webextension-polyfill";
import store from "../../store";
export default function onProxySettingsChange(
details: Types.SettingOnChangeDetailsType
) {
console.log(`⚙️ Proxy settings changed: ${details.levelOfControl}`);
store.state.chromiumProxyActive =
details.levelOfControl == "controlled_by_this_extension";
}

View File

@ -2,8 +2,12 @@ import { WebRequest } from "webextension-polyfill";
import findChannelFromTwitchTvUrl from "../../common/ts/findChannelFromTwitchTvUrl";
import findChannelFromVideoWeaverUrl from "../../common/ts/findChannelFromVideoWeaverUrl";
import getHostFromUrl from "../../common/ts/getHostFromUrl";
import getProxyInfoFromUrl from "../../common/ts/getProxyInfoFromUrl";
import isChromium from "../../common/ts/isChromium";
import isRequestTypeProxied from "../../common/ts/isRequestTypeProxied";
import {
getProxyInfoFromUrl,
getUrlFromProxyInfo,
} from "../../common/ts/proxyInfo";
import {
passportHostRegex,
twitchGqlHostRegex,
@ -13,7 +17,7 @@ import {
} from "../../common/ts/regexes";
import { getStreamStatus, setStreamStatus } from "../../common/ts/streamStatus";
import store from "../../store";
import type { ProxyInfo } from "../../types";
import { ProxyInfo, ProxyRequestType } from "../../types";
export default function onResponseStarted(
details: WebRequest.OnResponseStartedDetailsType & {
@ -25,30 +29,46 @@ export default function onResponseStarted(
const proxy = getProxyFromDetails(details);
// Twitch webpage requests.
if (store.state.proxyTwitchWebpage && twitchTvHostRegex.test(host)) {
const requestParams = {
isChromium: isChromium,
optimizedProxiesEnabled: store.state.optimizedProxiesEnabled,
passportLevel: store.state.passportLevel,
};
const proxiedPassportRequest = isRequestTypeProxied(
ProxyRequestType.Passport,
requestParams
);
const proxiedUsherRequest = isRequestTypeProxied(
ProxyRequestType.Usher,
requestParams
);
const proxiedVideoWeaverRequest = isRequestTypeProxied(
ProxyRequestType.VideoWeaver,
requestParams
);
const proxiedGraphQLRequest = isRequestTypeProxied(
ProxyRequestType.GraphQL,
requestParams
);
const proxiedTwitchWebpageRequest = isRequestTypeProxied(
ProxyRequestType.TwitchWebpage,
requestParams
);
// Passport requests.
if (proxiedPassportRequest && passportHostRegex.test(host)) {
if (!proxy) return console.log(`❌ Did not proxy ${details.url}`);
console.log(`✅ Proxied ${details.url} through ${proxy}`);
}
// Twitch GraphQL requests.
if (store.state.proxyTwitchWebpage && twitchGqlHostRegex.test(host)) {
if (!proxy && store.state.optimizedProxiesEnabled) return; // Expected for most requests.
if (!proxy) return console.log(`❌ Did not proxy ${details.url}`);
console.log(`✅ Proxied ${details.url} through ${proxy}`);
}
// Passport & Usher requests.
if (
store.state.proxyUsherRequests &&
(passportHostRegex.test(host) || usherHostRegex.test(host))
) {
// Usher requests.
if (proxiedUsherRequest && usherHostRegex.test(host)) {
if (!proxy) return console.log(`❌ Did not proxy ${details.url}`);
console.log(`✅ Proxied ${details.url} through ${proxy}`);
}
// Video-weaver requests.
if (videoWeaverHostRegex.test(host)) {
if (proxiedVideoWeaverRequest && videoWeaverHostRegex.test(host)) {
const channelName =
findChannelFromVideoWeaverUrl(details.url) ??
findChannelFromTwitchTvUrl(details.documentUrl);
@ -60,7 +80,7 @@ export default function onResponseStarted(
proxied: false,
proxyHost: streamStatus?.proxyHost ? streamStatus.proxyHost : undefined,
proxyCountry: streamStatus?.proxyCountry,
reason: `Proxied: ${stats.proxied} | [Not proxied]: ${stats.notProxied}`,
reason: `Proxied: ${stats.proxied} | Not proxied: ${stats.notProxied}`,
stats,
});
console.log(
@ -73,13 +93,26 @@ export default function onResponseStarted(
proxied: true,
proxyHost: proxy,
proxyCountry: streamStatus?.proxyCountry,
reason: `[Proxied]: ${stats.proxied} | Not proxied: ${stats.notProxied}`,
reason: `Proxied: ${stats.proxied} | Not proxied: ${stats.notProxied}`,
stats,
});
console.log(
`✅ Proxied ${details.url} (${channelName ?? "unknown"}) through ${proxy}`
);
}
// Twitch GraphQL requests.
if (proxiedGraphQLRequest && twitchGqlHostRegex.test(host)) {
if (!proxy && store.state.optimizedProxiesEnabled) return; // Expected for most requests.
if (!proxy) return console.log(`❌ Did not proxy ${details.url}`);
console.log(`✅ Proxied ${details.url} through ${proxy}`);
}
// Twitch webpage requests.
if (proxiedTwitchWebpageRequest && twitchTvHostRegex.test(host)) {
if (!proxy) return console.log(`❌ Did not proxy ${details.url}`);
console.log(`✅ Proxied ${details.url} through ${proxy}`);
}
}
function getProxyFromDetails(
@ -103,11 +136,11 @@ function getProxyFromDetails(
proxy => proxy.host === dnsResponse.host
);
if (possibleProxies.length === 1)
return `${possibleProxies[0].host}:${possibleProxies[0].port}`;
return getUrlFromProxyInfo(possibleProxies[0]);
return dnsResponse.host;
} else {
const proxyInfo = details.proxyInfo; // Firefox only.
if (!proxyInfo || proxyInfo.type === "direct") return null;
return `${proxyInfo.host}:${proxyInfo.port}`;
return getUrlFromProxyInfo(proxyInfo);
}
}

View File

@ -12,6 +12,10 @@ export default function onStartupStoreCleanup(): void {
if (store.readyState !== "complete")
return store.addEventListener("load", onStartupStoreCleanup);
const now = Date.now();
store.state.adLog = store.state.adLog.filter(
entry => now - entry.timestamp < 1000 * 60 * 60 * 24 * 7 // 7 days
);
store.state.chromiumProxyActive = false;
store.state.dnsResponses = [];
store.state.openedTwitchTabs = [];

View File

@ -16,9 +16,6 @@ export default function onTabCreated(tab: Tabs.Tab): void {
const host = getHostFromUrl(url);
if (!host) return;
// TODO: `twitchTvHostRegex` doesn't match `appeals.twitch.tv` and
// `dashboard.twitch.tv` which means that passport requests from those
// subdomains will not be proxied. This could mess up the cookie country.
if (twitchTvHostRegex.test(host)) {
console.log(` Opened Twitch tab: ${tab.id}`);
store.state.openedTwitchTabs.push(tab);

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,5 +1,5 @@
import ip from "ip";
import getProxyInfoFromUrl from "./getProxyInfoFromUrl";
import { getProxyInfoFromUrl } from "./proxyInfo";
/**
* Anonymize an IP address by masking the last 2 octets of an IPv4 address
@ -11,8 +11,6 @@ export function anonymizeIpAddress(url: string): string {
const proxyInfo = getProxyInfoFromUrl(url);
let proxyHost = proxyInfo.host;
const withinBrackets = /^\[.*\]$/.test(proxyHost);
if (withinBrackets) proxyHost = proxyHost.slice(1, -1);
const isIPv4 = ip.isV4Format(proxyHost);
const isIPv6 = ip.isV6Format(proxyHost);
@ -27,9 +25,7 @@ export function anonymizeIpAddress(url: string): string {
}
}
if (withinBrackets) proxyHost = `[${proxyHost}]`;
return `${proxyHost}:${proxyInfo.port}`;
return proxyHost; // Also anonymizes port.
}
/**

View File

@ -0,0 +1,254 @@
const alpha2 = {
AD: "Andorra",
AE: "United Arab Emirates",
AF: "Afghanistan",
AG: "Antigua and Barbuda",
AI: "Anguilla",
AL: "Albania",
AM: "Armenia",
AO: "Angola",
AQ: "Antarctica",
AR: "Argentina",
AS: "American Samoa",
AT: "Austria",
AU: "Australia",
AW: "Aruba",
AX: "Åland Islands",
AZ: "Azerbaijan",
BA: "Bosnia and Herzegovina",
BB: "Barbados",
BD: "Bangladesh",
BE: "Belgium",
BF: "Burkina Faso",
BG: "Bulgaria",
BH: "Bahrain",
BI: "Burundi",
BJ: "Benin",
BL: "Saint Barthélemy",
BM: "Bermuda",
BN: "Brunei Darussalam",
BO: "Bolivia",
BQ: "Bonaire, Sint Eustatius and Saba",
BR: "Brazil",
BS: "Bahamas",
BT: "Bhutan",
BV: "Bouvet Island",
BW: "Botswana",
BY: "Belarus",
BZ: "Belize",
CA: "Canada",
CC: "Cocos (Keeling) Islands",
CD: "Democratic Republic of the Congo",
CF: "Central African Republic",
CG: "Republic of the Congo",
CH: "Switzerland",
CI: "Côte d'Ivoire",
CK: "Cook Islands",
CL: "Chile",
CM: "Cameroon",
CN: "China",
CO: "Colombia",
CR: "Costa Rica",
CU: "Cuba",
CV: "Cabo Verde",
CW: "Curaçao",
CX: "Christmas Island",
CY: "Cyprus",
CZ: "Czechia",
DE: "Germany",
DJ: "Djibouti",
DK: "Denmark",
DM: "Dominica",
DO: "Dominican Republic",
DZ: "Algeria",
EC: "Ecuador",
EE: "Estonia",
EG: "Egypt",
EH: "Western Sahara",
ER: "Eritrea",
ES: "Spain",
ET: "Ethiopia",
FI: "Finland",
FJ: "Fiji",
FK: "Falkland Islands",
FM: "Micronesia",
FO: "Faroe Islands",
FR: "France",
GA: "Gabon",
GB: "United Kingdom",
GD: "Grenada",
GE: "Georgia",
GF: "French Guiana",
GG: "Guernsey",
GH: "Ghana",
GI: "Gibraltar",
GL: "Greenland",
GM: "Gambia",
GN: "Guinea",
GP: "Guadeloupe",
GQ: "Equatorial Guinea",
GR: "Greece",
GS: "South Georgia and the South Sandwich Islands",
GT: "Guatemala",
GU: "Guam",
GW: "Guinea-Bissau",
GY: "Guyana",
HK: "Hong Kong",
HM: "Heard Island and McDonald Islands",
HN: "Honduras",
HR: "Croatia",
HT: "Haiti",
HU: "Hungary",
ID: "Indonesia",
IE: "Ireland",
IL: "Israel",
IM: "Isle of Man",
IN: "India",
IO: "British Indian Ocean Territory",
IQ: "Iraq",
IR: "Iran",
IS: "Iceland",
IT: "Italy",
JE: "Jersey",
JM: "Jamaica",
JO: "Jordan",
JP: "Japan",
KE: "Kenya",
KG: "Kyrgyzstan",
KH: "Cambodia",
KI: "Kiribati",
KM: "Comoros",
KN: "Saint Kitts and Nevis",
KP: "North Korea",
KR: "South Korea",
KW: "Kuwait",
KY: "Cayman Islands",
KZ: "Kazakhstan",
LA: "Laos",
LB: "Lebanon",
LC: "Saint Lucia",
LI: "Liechtenstein",
LK: "Sri Lanka",
LR: "Liberia",
LS: "Lesotho",
LT: "Lithuania",
LU: "Luxembourg",
LV: "Latvia",
LY: "Libya",
MA: "Morocco",
MC: "Monaco",
MD: "Moldova",
ME: "Montenegro",
MF: "Saint Martin",
MG: "Madagascar",
MH: "Marshall Islands",
MK: "North Macedonia",
ML: "Mali",
MM: "Myanmar",
MN: "Mongolia",
MO: "Macao",
MP: "Northern Mariana Islands",
MQ: "Martinique",
MR: "Mauritania",
MS: "Montserrat",
MT: "Malta",
MU: "Mauritius",
MV: "Maldives",
MW: "Malawi",
MX: "Mexico",
MY: "Malaysia",
MZ: "Mozambique",
NA: "Namibia",
NC: "New Caledonia",
NE: "Niger",
NF: "Norfolk Island",
NG: "Nigeria",
NI: "Nicaragua",
NL: "Netherlands",
NO: "Norway",
NP: "Nepal",
NR: "Nauru",
NU: "Niue",
NZ: "New Zealand",
OM: "Oman",
PA: "Panama",
PE: "Peru",
PF: "French Polynesia",
PG: "Papua New Guinea",
PH: "Philippines",
PK: "Pakistan",
PL: "Poland",
PM: "Saint Pierre and Miquelon",
PN: "Pitcairn",
PR: "Puerto Rico",
PS: "Palestine",
PT: "Portugal",
PW: "Palau",
PY: "Paraguay",
QA: "Qatar",
RE: "Réunion",
RO: "Romania",
RS: "Serbia",
RU: "Russia",
RW: "Rwanda",
SA: "Saudi Arabia",
SB: "Solomon Islands",
SC: "Seychelles",
SD: "Sudan",
SE: "Sweden",
SG: "Singapore",
SH: "Saint Helena, Ascension and Tristan da Cunha",
SI: "Slovenia",
SJ: "Svalbard and Jan Mayen",
SK: "Slovakia",
SL: "Sierra Leone",
SM: "San Marino",
SN: "Senegal",
SO: "Somalia",
SR: "Suriname",
SS: "South Sudan",
ST: "Sao Tome and Principe",
SV: "El Salvador",
SX: "Sint Maarten",
SY: "Syria",
SZ: "Eswatini",
TC: "Turks and Caicos Islands",
TD: "Chad",
TF: "French Southern Territories",
TG: "Togo",
TH: "Thailand",
TJ: "Tajikistan",
TK: "Tokelau",
TL: "Timor-Leste",
TM: "Turkmenistan",
TN: "Tunisia",
TO: "Tonga",
TR: "Türkiye",
TT: "Trinidad and Tobago",
TV: "Tuvalu",
TW: "Taiwan",
TZ: "Tanzania",
UA: "Ukraine",
UG: "Uganda",
UK: "United Kingdom",
UM: "United States Minor Outlying Islands",
US: "United States",
UY: "Uruguay",
UZ: "Uzbekistan",
VA: "Vatican City",
VC: "Saint Vincent and the Grenadines",
VE: "Venezuela",
VG: "Virgin Islands (British)",
VI: "Virgin Islands (U.S.)",
VN: "Vietnam",
VU: "Vanuatu",
WF: "Wallis and Futuna",
WS: "Samoa",
YE: "Yemen",
YT: "Mayotte",
ZA: "South Africa",
ZM: "Zambia",
ZW: "Zimbabwe",
};
export { alpha2 };

View File

@ -0,0 +1,92 @@
import { ProxyRequestParams, ProxyRequestType } from "../../types";
export default function isRequestTypeProxied(
type: ProxyRequestType,
params: ProxyRequestParams
): boolean {
if (type === ProxyRequestType.Passport) {
if (params.isChromium && !params.optimizedProxiesEnabled) {
return params.passportLevel >= 0;
} else {
return params.passportLevel >= 1;
}
}
if (type === ProxyRequestType.Usher) {
if (params.optimizedProxiesEnabled) {
if (params.isChromium && params.fullModeEnabled === false) {
return false;
}
if (!params.isChromium && params.isFlagged === false) {
return false;
}
}
return params.passportLevel >= 0;
}
if (type === ProxyRequestType.VideoWeaver) {
if (params.optimizedProxiesEnabled) {
if (params.isChromium && params.fullModeEnabled === false) {
return false;
}
if (!params.isChromium && params.isFlagged === false) {
return false;
}
}
return true;
}
if (type === ProxyRequestType.GraphQLToken) {
if (params.isChromium) {
return params.passportLevel >= 1;
} else {
return params.passportLevel >= 0;
}
}
if (type === ProxyRequestType.GraphQLIntegrity) {
if (params.optimizedProxiesEnabled) {
return params.passportLevel >= 2;
} else {
return params.passportLevel >= 1;
}
}
if (type === ProxyRequestType.GraphQL) {
// Proxy all GQL requests when passport is unoptimized official+ (Chromium)
// or unoptimized diplomatic+ (Firefox).
if (
params.isChromium &&
!params.optimizedProxiesEnabled &&
params.passportLevel >= 1
) {
return true;
}
if (
!params.isChromium &&
!params.optimizedProxiesEnabled &&
params.passportLevel >= 2
) {
return true;
}
// Proxy flagged GQL requests when passport is official+ (Chromium) or
// ordinary+ (Firefox).
if (params.isChromium && params.fullModeEnabled === false) {
return false;
}
if (!params.isChromium && params.isFlagged === false) {
return false;
}
if (params.isChromium) {
return params.passportLevel >= 1;
} else {
return params.passportLevel >= 0;
}
}
if (type === ProxyRequestType.TwitchWebpage) {
return params.passportLevel >= 2;
}
return false;
}

View File

@ -1,6 +1,7 @@
import ip from "ip";
import type { ProxyInfo } from "../../types";
export default function getProxyInfoFromUrl(
export function getProxyInfoFromUrl(
url: string
): ProxyInfo & { type: "http"; host: string; port: number } {
const lastIndexOfAt = url.lastIndexOf("@");
@ -16,6 +17,9 @@ export default function getProxyInfoFromUrl(
host = hostname.substring(0, lastIndexOfColon);
port = Number(hostname.substring(lastIndexOfColon + 1, hostname.length));
}
if (host.startsWith("[") && host.endsWith("]")) {
host = host.substring(1, host.length - 1);
}
let username: string | undefined = undefined;
let password: string | undefined = undefined;
@ -57,3 +61,21 @@ function getLastIndexOfColon(hostname: string): number {
}
return lastIndexOfColon;
}
export function getUrlFromProxyInfo(proxyInfo: ProxyInfo): string {
const { host, port, username, password } = proxyInfo;
if (!host) return "";
let url = "";
if (username && password) {
url = `${username}:${password}@`;
} else if (username) {
url = `${username}@`;
}
if (ip.isV6Format(host)) {
url += `[${host}]`;
} else {
url += host;
}
if (port) url += `:${port}`;
return url;
}

View File

@ -1,5 +1,7 @@
import store from "../../store";
import getProxyInfoFromUrl from "./getProxyInfoFromUrl";
import { ProxyRequestType } from "../../types";
import isRequestTypeProxied from "./isRequestTypeProxied";
import { getProxyInfoFromUrl, getUrlFromProxyInfo } from "./proxyInfo";
import {
passportHostRegex,
twitchGqlHostRegex,
@ -9,29 +11,66 @@ import {
} from "./regexes";
import updateDnsResponses from "./updateDnsResponses";
export function updateProxySettings() {
const { proxyTwitchWebpage, proxyUsherRequests } = store.state;
export function updateProxySettings(requestFilter?: ProxyRequestType[]) {
const { optimizedProxiesEnabled, passportLevel } = store.state;
const proxies = store.state.optimizedProxiesEnabled
const proxies = optimizedProxiesEnabled
? store.state.optimizedProxies
: store.state.normalProxies;
const proxyInfoString = getProxyInfoStringFromUrls(proxies);
const getRequestParams = (requestType: ProxyRequestType) => ({
isChromium: true,
optimizedProxiesEnabled: optimizedProxiesEnabled,
passportLevel: passportLevel,
fullModeEnabled:
!optimizedProxiesEnabled ||
(requestFilter != null && requestFilter.includes(requestType)),
});
const proxyPassportRequests = isRequestTypeProxied(
ProxyRequestType.Passport,
getRequestParams(ProxyRequestType.Passport)
);
const proxyUsherRequests = isRequestTypeProxied(
ProxyRequestType.Usher,
getRequestParams(ProxyRequestType.Usher)
);
const proxyVideoWeaverRequests = isRequestTypeProxied(
ProxyRequestType.VideoWeaver,
getRequestParams(ProxyRequestType.VideoWeaver)
);
const proxyGraphQLRequests = isRequestTypeProxied(
ProxyRequestType.GraphQL,
getRequestParams(ProxyRequestType.GraphQL)
);
const proxyTwitchWebpageRequests = isRequestTypeProxied(
ProxyRequestType.TwitchWebpage,
getRequestParams(ProxyRequestType.TwitchWebpage)
);
const config = {
mode: "pac_script",
pacScript: {
data: `
function FindProxyForURL(url, host) {
// Twitch webpage & GraphQL requests.
if (${proxyTwitchWebpage} && (${twitchTvHostRegex}.test(host) || ${twitchGqlHostRegex}.test(host))) {
// Passport requests.
if (${proxyPassportRequests} && ${passportHostRegex}.test(host)) {
return "${proxyInfoString}";
}
// Passport & Usher requests.
if (${proxyUsherRequests} && (${passportHostRegex}.test(host) || ${usherHostRegex}.test(host))) {
// Usher requests.
if (${proxyUsherRequests} && ${usherHostRegex}.test(host)) {
return "${proxyInfoString}";
}
// Video Weaver requests.
if (${videoWeaverHostRegex}.test(host)) {
if (${proxyVideoWeaverRequests} && ${videoWeaverHostRegex}.test(host)) {
return "${proxyInfoString}";
}
// GraphQL requests.
if (${proxyGraphQLRequests} && ${twitchGqlHostRegex}.test(host)) {
return "${proxyInfoString}";
}
// Twitch webpage requests.
if (${proxyTwitchWebpageRequests} && ${twitchTvHostRegex}.test(host)) {
return "${proxyInfoString}";
}
return "DIRECT";
@ -44,6 +83,7 @@ export function updateProxySettings() {
console.log(
`⚙️ Proxying requests through one of: ${proxies.toString() || "<empty>"}`
);
store.state.chromiumProxyActive = true;
updateDnsResponses();
});
}
@ -52,7 +92,12 @@ function getProxyInfoStringFromUrls(urls: string[]): string {
return [
...urls.map(url => {
const proxyInfo = getProxyInfoFromUrl(url);
return `PROXY ${proxyInfo.host}:${proxyInfo.port}`;
return `PROXY ${getUrlFromProxyInfo({
...proxyInfo,
// Don't include username/password in PAC script.
username: undefined,
password: undefined,
})}`;
}),
"DIRECT",
].join("; ");
@ -61,5 +106,6 @@ function getProxyInfoStringFromUrls(urls: string[]): string {
export function clearProxySettings() {
chrome.proxy.settings.clear({ scope: "regular" }, function () {
console.log("⚙️ Proxy settings cleared");
store.state.chromiumProxyActive = false;
});
}

View File

@ -1,7 +1,7 @@
export const passportHostRegex = /^passport\.twitch\.tv$/i;
export const twitchApiChannelNameRegex = /\/hls\/(.+)\.m3u8/i;
export const twitchChannelNameRegex =
/^https?:\/\/(?:www|m)\.twitch\.tv\/(?:videos\/|popout\/)?(\w+)/i;
/^https?:\/\/(?:www|m)\.twitch\.tv\/(?:videos\/|popout\/)?((?!(?:directory|jobs|p|privacy|store|turbo)\b)\w+)/i;
export const twitchGqlHostRegex = /^gql\.twitch\.tv$/i;
export const twitchTvHostRegex = /^(?:www|m)\.twitch\.tv$/i;
export const usherHostRegex = /^usher\.ttvnw\.net$/i;

View File

@ -0,0 +1,8 @@
export default function toAbsoluteUrl(url: string): string {
try {
const Url = new URL(url, location.href);
return Url.href;
} catch {
return url;
}
}

View File

@ -1,7 +1,7 @@
import ip from "ip";
import store from "../../store";
import type { DnsResponse } from "../../types";
import getProxyInfoFromUrl from "./getProxyInfoFromUrl";
import { getProxyInfoFromUrl } from "./proxyInfo";
export default async function updateDnsResponses() {
const proxies = [

View File

@ -1,18 +1,24 @@
import pageScriptURL from "url:../page/page.ts";
import workerScriptURL from "url:../page/worker.ts";
import browser, { Storage } from "webextension-polyfill";
import findChannelFromTwitchTvUrl from "../common/ts/findChannelFromTwitchTvUrl";
import isChromium from "../common/ts/isChromium";
import { getStreamStatus, setStreamStatus } from "../common/ts/streamStatus";
import store from "../store";
import { State } from "../store/types";
import { MessageType } from "../types";
console.info("[TTV LOL PRO] 🚀 Content script running.");
console.info("[TTV LOL PRO] Content script running.");
injectPageScript();
if (isChromium) injectPageScript();
// Firefox uses FilterResponseData to inject the page script.
if (store.readyState === "complete") onStoreReady();
else store.addEventListener("load", onStoreReady);
if (store.readyState === "complete") onStoreLoad();
else store.addEventListener("load", onStoreLoad);
store.addEventListener("change", onStoreChange);
window.addEventListener("message", onMessage);
browser.runtime.onMessage.addListener(onBackgroundMessage);
window.addEventListener("message", onPageMessage);
function injectPageScript() {
// From https://stackoverflow.com/a/9517879
@ -29,19 +35,10 @@ function injectPageScript() {
// Please note that this does NOT involve remote code execution. The injected scripts are bundled
// with the extension. The `url:` imports above are used to get the runtime URLs of the respective scripts.
// Additionally, there is no custom Content Security Policy (CSP) in use.
(document.head || document.documentElement).append(script); // Note: Despite what the TS types say, `document.head` can be `null`.
(document.head || document.documentElement).prepend(script); // Note: Despite what the TS types say, `document.head` can be `null`.
}
function onStoreReady() {
// Send store state to page script.
const message = {
type: "StoreReady",
state: JSON.parse(JSON.stringify(store.state)),
};
window.postMessage({
type: "PageScriptMessage",
message,
});
function onStoreLoad() {
// Clear stats for stream on page load/reload.
clearStats();
}
@ -51,32 +48,113 @@ function onStoreReady() {
* @returns
*/
function clearStats() {
// TODO: Clear stats on navigation.
const channelName = findChannelFromTwitchTvUrl(location.href);
if (!channelName) return;
if (store.state.streamStatuses.hasOwnProperty(channelName)) {
store.state.streamStatuses[channelName].stats = {
proxied: 0,
notProxied: 0,
};
setStreamStatus(channelName, {
proxied: false,
reason: "",
});
}
console.log(
`[TTV LOL PRO] Cleared stats for channel '${channelName}' (content script).`
);
}
function onStoreChange(changes: Record<string, Storage.StorageChange>) {
const changedKeys = Object.keys(changes) as (keyof State)[];
// This is mainly to reduce the amount of messages sent to the page script.
// (Also to reduce the number of console logs.)
const ignoredKeys: (keyof State)[] = [
"adLog",
"dnsResponses",
"openedTwitchTabs",
"streamStatuses",
"videoWeaverUrlsByChannel",
];
if (changedKeys.every(key => ignoredKeys.includes(key))) return;
console.log("[TTV LOL PRO] Store changed:", changes);
window.postMessage({
type: MessageType.PageScriptMessage,
message: {
type: MessageType.GetStoreStateResponse,
state: JSON.parse(JSON.stringify(store.state)),
},
});
}
function onBackgroundMessage(message: any) {
switch (message.type) {
case MessageType.EnableFullModeResponse:
window.postMessage({
type: MessageType.PageScriptMessage,
message,
});
window.postMessage({
type: MessageType.WorkerScriptMessage,
message,
});
break;
}
}
function onMessage(event: MessageEvent) {
if (event.source !== window) return;
if (event.data?.type === "UsherResponse") {
const { channel, videoWeaverUrls, proxyCountry } = event.data;
// Update Video Weaver URLs.
store.state.videoWeaverUrlsByChannel[channel] = [
...(store.state.videoWeaverUrlsByChannel[channel] ?? []),
...videoWeaverUrls,
];
// Update proxy country.
const streamStatus = getStreamStatus(channel);
setStreamStatus(channel, {
...(streamStatus ?? { proxied: false, reason: "" }),
proxyCountry,
});
function onPageMessage(event: MessageEvent) {
if (event.data?.type !== MessageType.ContentScriptMessage) return;
const message = event.data?.message;
if (!message) return;
switch (message.type) {
case MessageType.GetStoreState:
const sendStoreState = () => {
window.postMessage({
type: MessageType.PageScriptMessage,
message: {
type: MessageType.GetStoreStateResponse,
state: JSON.parse(JSON.stringify(store.state)),
},
});
};
if (store.readyState === "complete") sendStoreState();
else store.addEventListener("load", sendStoreState);
break;
case MessageType.EnableFullMode:
try {
browser.runtime.sendMessage(message);
} catch (error) {
console.error(
"[TTV LOL PRO] Failed to send EnableFullMode message",
error
);
}
break;
case MessageType.DisableFullMode:
try {
browser.runtime.sendMessage(message);
} catch (error) {
console.error(
"[TTV LOL PRO] Failed to send DisableFullMode message",
error
);
}
break;
case MessageType.UsherResponse:
const { channel, videoWeaverUrls, proxyCountry } = message;
// Update Video Weaver URLs.
store.state.videoWeaverUrlsByChannel[channel] = [
...(store.state.videoWeaverUrlsByChannel[channel] ?? []),
...videoWeaverUrls,
];
// Update proxy country.
const streamStatus = getStreamStatus(channel);
setStreamStatus(channel, {
...(streamStatus ?? { proxied: false, reason: "" }),
proxyCountry,
});
break;
case MessageType.ClearStats:
clearStats();
break;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

73
src/m3u8-parser.d.ts vendored Normal file
View File

@ -0,0 +1,73 @@
declare module "m3u8-parser" {
// https://github.com/videojs/m3u8-parser#parsed-output
interface Manifest {
allowCache: boolean;
endList?: boolean;
mediaSequence?: number;
dateRanges: [];
discontinuitySequence?: number;
playlistType?: string;
custom?: {};
playlists?: {
attributes: {
"FRAME-RATE": number;
VIDEO: string;
CODECS: string;
RESOLUTION: {
width: number;
height: number;
};
BANDWIDTH: number;
};
uri: string;
timeline: number;
}[];
mediaGroups?: {
AUDIO: {};
VIDEO: {};
"CLOSED-CAPTIONS": {};
SUBTITLES: {};
};
dateTimeString?: string;
dateTimeObject?: Date;
targetDuration?: number;
totalDuration?: number;
discontinuityStarts: number[];
segments: {
title: string;
byterange: {
length: number;
offset: number;
};
duration: number;
programDateTime: number;
attributes: {};
discontinuity: number;
uri: string;
timeline: number;
key: {
method: string;
uri: string;
iv: string;
};
map: {
uri: string;
byterange: {
length: number;
offset: number;
};
};
"cue-out": string;
"cue-out-cont": string;
"cue-in": string;
custom: {};
}[];
}
export class Parser {
constructor();
push(chunk: string): void;
end(): void;
manifest: Manifest;
}
}

View File

@ -2,7 +2,8 @@
"manifest_version": 3,
"name": "TTV LOL PRO",
"description": "TTV LOL PRO removes most livestream ads from Twitch.",
"version": "2.2.3",
"homepage_url": "https://github.com/younesaassila/ttv-lol-pro",
"version": "2.3.0",
"background": {
"service_worker": "background/background.ts",
"type": "module"
@ -18,7 +19,7 @@
},
"action": {
"default_icon": {
"128": "images/brand/icon.png"
"128": "common/images/brand/icon.png"
},
"default_title": "TTV LOL PRO",
"default_popup": "popup/menu.html"
@ -31,7 +32,7 @@
}
],
"icons": {
"128": "images/brand/icon.png"
"128": "common/images/brand/icon.png"
},
"options_ui": {
"browser_style": false,

View File

@ -2,14 +2,15 @@
"manifest_version": 2,
"name": "TTV LOL PRO",
"description": "TTV LOL PRO removes most livestream ads from Twitch.",
"version": "2.2.3",
"homepage_url": "https://github.com/younesaassila/ttv-lol-pro",
"version": "2.3.0",
"background": {
"scripts": ["background/background.ts"],
"persistent": false
},
"browser_action": {
"default_icon": {
"128": "images/brand/icon.png"
"128": "common/images/brand/icon.png"
},
"default_title": "TTV LOL PRO",
"default_popup": "popup/menu.html"
@ -28,7 +29,7 @@
}
],
"icons": {
"128": "images/brand/icon.png"
"128": "common/images/brand/icon.png"
},
"options_ui": {
"browser_style": false,

View File

@ -1,7 +1,12 @@
import Bowser from "bowser";
import browser from "webextension-polyfill";
import $ from "../common/ts/$";
import { readFile, saveFile } from "../common/ts/file";
import getProxyInfoFromUrl from "../common/ts/getProxyInfoFromUrl";
import findChannelFromTwitchTvUrl from "../common/ts/findChannelFromTwitchTvUrl";
import isChannelWhitelisted from "../common/ts/isChannelWhitelisted";
import isChromium from "../common/ts/isChromium";
import isRequestTypeProxied from "../common/ts/isRequestTypeProxied";
import { getProxyInfoFromUrl } from "../common/ts/proxyInfo";
import {
clearProxySettings,
updateProxySettings,
@ -10,7 +15,7 @@ import sendAdLog from "../common/ts/sendAdLog";
import store from "../store";
import getDefaultState from "../store/getDefaultState";
import type { State } from "../store/types";
import type { KeyOfType } from "../types";
import { KeyOfType, ProxyRequestType } from "../types";
//#region Types
type AllowedResult = [boolean, string?];
@ -31,29 +36,45 @@ type ListOptions = {
//#endregion
//#region HTML Elements
// Proxy settings
const proxyUsherRequestsCheckboxElement = $(
"#proxy-usher-requests-checkbox"
// Import/Export
const exportButtonElement = $("#export-button") as HTMLButtonElement;
const importButtonElement = $("#import-button") as HTMLButtonElement;
const resetButtonElement = $("#reset-button") as HTMLButtonElement;
// Passport
const passportLevelSliderElement = $(
"#passport-level-slider"
) as HTMLInputElement;
const proxyTwitchWebpageCheckboxElement = $(
"#proxy-twitch-webpage-checkbox"
) as HTMLInputElement;
const anonymousModeLiElement = $("#anonymous-mode-li") as HTMLLIElement;
const passportLevelWarningElement = $("#passport-level-warning") as HTMLElement;
const anonymousModeCheckboxElement = $(
"#anonymous-mode-checkbox"
) as HTMLInputElement;
// Whitelisted channels
const whitelistedChannelsSectionElement = $(
"#whitelisted-channels-section"
// Proxy usage
const passportLevelProxyUsageElement = $(
"#passport-level-proxy-usage"
) as HTMLDetailsElement;
const passportLevelProxyUsageSummaryElement = $(
"#passport-level-proxy-usage-summary"
) as HTMLElement;
const passportLevelProxyUsagePassportElement = $(
"#passport-level-proxy-usage-passport"
) as HTMLTableCellElement;
const passportLevelProxyUsageUsherElement = $(
"#passport-level-proxy-usage-usher"
) as HTMLTableCellElement;
const passportLevelProxyUsageVideoWeaverElement = $(
"#passport-level-proxy-usage-video-weaver"
) as HTMLTableCellElement;
const passportLevelProxyUsageGqlElement = $(
"#passport-level-proxy-usage-gql"
) as HTMLTableCellElement;
const passportLevelProxyUsageWwwElement = $(
"#passport-level-proxy-usage-www"
) as HTMLTableCellElement;
// Whitelisted channels
const whitelistedChannelsListElement = $(
"#whitelisted-channels-list"
) as HTMLUListElement;
$;
// Proxies
const optimizedProxiesDivElement = $(
"#optimized-proxies-div"
) as HTMLDivElement;
const optimizedProxiesInputElement = $("#optimized") as HTMLInputElement;
const optimizedProxiesListElement = $(
"#optimized-proxies-list"
@ -61,7 +82,6 @@ const optimizedProxiesListElement = $(
const normalProxiesInputElement = $("#normal") as HTMLInputElement;
const normalProxiesListElement = $("#normal-proxies-list") as HTMLOListElement;
// Ad log
const adLogSectionElement = $("#ad-log-section") as HTMLElement;
const adLogEnabledCheckboxElement = $(
"#ad-log-enabled-checkbox"
) as HTMLInputElement;
@ -70,13 +90,15 @@ const adLogExportButtonElement = $(
"#ad-log-export-button"
) as HTMLButtonElement;
const adLogClearButtonElement = $("#ad-log-clear-button") as HTMLButtonElement;
// Import/Export
const exportButtonElement = $("#export-button") as HTMLButtonElement;
const importButtonElement = $("#import-button") as HTMLButtonElement;
const resetButtonElement = $("#reset-button") as HTMLButtonElement;
// Troubleshooting
const twitchTabsReportButtonElement = $(
"#twitch-tabs-report-button"
) as HTMLButtonElement;
const unsetPacScriptButtonElement = $(
"#unset-pac-script-button"
) as HTMLButtonElement;
// Footer
const versionElement = $("#version") as HTMLParagraphElement;
//#endregion
const DEFAULT_STATE = Object.freeze(getDefaultState());
@ -96,52 +118,55 @@ if (store.readyState === "complete") main();
else store.addEventListener("load", main);
function main() {
// Proxy settings
proxyUsherRequestsCheckboxElement.checked = store.state.proxyUsherRequests;
proxyUsherRequestsCheckboxElement.addEventListener("change", () => {
const checked = proxyUsherRequestsCheckboxElement.checked;
store.state.proxyUsherRequests = checked;
// Remove elements that are only for Chromium or Firefox.
document
.querySelectorAll(isChromium ? ".firefox-only" : ".chromium-only")
.forEach(element => element.remove());
// Passport
passportLevelSliderElement.value = store.state.passportLevel.toString();
passportLevelSliderElement.addEventListener("input", () => {
store.state.passportLevel = parseInt(passportLevelSliderElement.value);
if (isChromium && store.state.chromiumProxyActive) {
updateProxySettings();
}
updateProxyUsage();
});
proxyTwitchWebpageCheckboxElement.checked = store.state.proxyTwitchWebpage;
proxyTwitchWebpageCheckboxElement.addEventListener("change", () => {
store.state.proxyTwitchWebpage = proxyTwitchWebpageCheckboxElement.checked;
if (isChromium && store.state.chromiumProxyActive) {
updateProxySettings();
}
updateProxyUsage();
anonymousModeCheckboxElement.checked = store.state.anonymousMode;
anonymousModeCheckboxElement.addEventListener("change", () => {
store.state.anonymousMode = anonymousModeCheckboxElement.checked;
});
// TODO: Figure out why this feature doesn't work in Chromium.
if (isChromium) {
anonymousModeLiElement.style.display = "none";
} else {
anonymousModeCheckboxElement.checked = store.state.anonymousMode;
anonymousModeCheckboxElement.addEventListener("change", () => {
store.state.anonymousMode = anonymousModeCheckboxElement.checked;
});
}
// Whitelisted channels
listInit(whitelistedChannelsListElement, "whitelistedChannels", {
getAlreadyExistsAlertMessage: channelName =>
`'${channelName}' is already whitelisted`,
getPromptPlaceholder: () => "Enter a channel name…",
isAddAllowed(text) {
if (!/^[a-z0-9_]+$/i.test(text)) {
return [false, `'${text}' is not a valid channel name`];
}
return [true];
},
isEditAllowed(text) {
if (!/^[a-z0-9_]+$/i.test(text)) {
return [false, `'${text}' is not a valid channel name`];
}
return [true];
},
});
// Proxies
if (isChromium) {
optimizedProxiesDivElement.style.display = "none";
normalProxiesInputElement.checked = true;
} else {
if (store.state.optimizedProxiesEnabled)
optimizedProxiesInputElement.checked = true;
else normalProxiesInputElement.checked = true;
const onProxyTypeChange = () => {
store.state.optimizedProxiesEnabled =
optimizedProxiesInputElement.checked;
};
optimizedProxiesInputElement.addEventListener("change", onProxyTypeChange);
normalProxiesInputElement.addEventListener("change", onProxyTypeChange);
}
if (store.state.optimizedProxiesEnabled)
optimizedProxiesInputElement.checked = true;
else normalProxiesInputElement.checked = true;
const onProxyTypeChange = () => {
store.state.optimizedProxiesEnabled = optimizedProxiesInputElement.checked;
if (isChromium && store.state.chromiumProxyActive) {
updateProxySettings();
}
updateProxyUsage();
};
optimizedProxiesInputElement.addEventListener("change", onProxyTypeChange);
normalProxiesInputElement.addEventListener("change", onProxyTypeChange);
listInit(optimizedProxiesListElement, "optimizedProxies", {
getPromptPlaceholder: insertMode => {
if (insertMode == "prepend") return "Enter a proxy URL… (Primary)";
@ -149,6 +174,11 @@ function main() {
},
isAddAllowed: isOptimizedProxyUrlAllowed,
isEditAllowed: isOptimizedProxyUrlAllowed,
onEdit() {
if (isChromium && store.state.chromiumProxyActive) {
updateProxySettings();
}
},
hidePromptMarker: true,
insertMode: "both",
});
@ -168,16 +198,85 @@ function main() {
insertMode: "both",
});
// Ad log
if (isChromium) {
adLogSectionElement.style.display = "none";
adLogEnabledCheckboxElement.checked = store.state.adLogEnabled;
adLogEnabledCheckboxElement.addEventListener("change", () => {
store.state.adLogEnabled = adLogEnabledCheckboxElement.checked;
});
// Footer
versionElement.textContent = `Version ${
browser.runtime.getManifest().version
}`;
}
function updateProxyUsage() {
const requestParams = {
isChromium: isChromium,
optimizedProxiesEnabled: store.state.optimizedProxiesEnabled,
passportLevel: store.state.passportLevel,
fullModeEnabled: false,
isFlagged: false,
};
// Proxy usage label.
let usageScore = 0;
// Unoptimized mode penalty.
if (!store.state.optimizedProxiesEnabled) usageScore += 1;
// GraphQL integrity penalty and warning.
if (isRequestTypeProxied(ProxyRequestType.GraphQLIntegrity, requestParams)) {
usageScore += 1;
passportLevelWarningElement.style.display = "block";
} else {
adLogEnabledCheckboxElement.checked = store.state.adLogEnabled;
adLogEnabledCheckboxElement.addEventListener("change", () => {
store.state.adLogEnabled = adLogEnabledCheckboxElement.checked;
});
passportLevelWarningElement.style.display = "none";
}
if (!isChromium) {
unsetPacScriptButtonElement.style.display = "none";
switch (usageScore) {
case 0:
passportLevelProxyUsageSummaryElement.textContent = "🙂 Low proxy usage";
passportLevelProxyUsageElement.dataset.usage = "low";
break;
case 1:
passportLevelProxyUsageSummaryElement.textContent =
"😐 Medium proxy usage";
passportLevelProxyUsageElement.dataset.usage = "medium";
break;
case 2:
passportLevelProxyUsageSummaryElement.textContent = "🙁 High proxy usage";
passportLevelProxyUsageElement.dataset.usage = "high";
break;
}
// Passport
if (isRequestTypeProxied(ProxyRequestType.Passport, requestParams)) {
passportLevelProxyUsagePassportElement.textContent = "All";
} else {
passportLevelProxyUsagePassportElement.textContent = "None";
}
// Usher
passportLevelProxyUsageUsherElement.textContent = "All";
// Video Weaver
if (isRequestTypeProxied(ProxyRequestType.VideoWeaver, requestParams)) {
passportLevelProxyUsageVideoWeaverElement.textContent = "All";
} else {
passportLevelProxyUsageVideoWeaverElement.textContent = "Few";
}
// GraphQL
if (isRequestTypeProxied(ProxyRequestType.GraphQL, requestParams)) {
passportLevelProxyUsageGqlElement.textContent = "All";
} else if (
isRequestTypeProxied(ProxyRequestType.GraphQLIntegrity, requestParams)
) {
passportLevelProxyUsageGqlElement.textContent = "Some";
} else if (
isRequestTypeProxied(ProxyRequestType.GraphQLToken, requestParams)
) {
passportLevelProxyUsageGqlElement.textContent = "Few";
} else {
passportLevelProxyUsageGqlElement.textContent = "None";
}
// WWW
if (isRequestTypeProxied(ProxyRequestType.TwitchWebpage, requestParams)) {
passportLevelProxyUsageWwwElement.textContent = "All";
} else {
passportLevelProxyUsageWwwElement.textContent = "None";
}
}
@ -425,33 +524,6 @@ function _listPrompt(
if (options.focusPrompt) promptInput.focus();
}
adLogSendButtonElement.addEventListener("click", async () => {
const success = await sendAdLog();
if (success === null) {
return alert("No log entries to send.");
}
if (!success) {
return alert("Failed to send log.");
}
alert("Log sent successfully.");
});
adLogExportButtonElement.addEventListener("click", () => {
saveFile(
"ttv-lol-pro_ad-log.json",
JSON.stringify(store.state.adLog),
"application/json;charset=utf-8"
);
});
adLogClearButtonElement.addEventListener("click", () => {
const confirmation = confirm(
"Are you sure you want to clear the ad log? This cannot be undone."
);
if (!confirmation) return;
store.state.adLog = [];
});
exportButtonElement.addEventListener("click", () => {
saveFile(
"ttv-lol-pro_backup.json",
@ -461,8 +533,7 @@ exportButtonElement.addEventListener("click", () => {
normalProxies: store.state.normalProxies,
optimizedProxies: store.state.optimizedProxies,
optimizedProxiesEnabled: store.state.optimizedProxiesEnabled,
proxyTwitchWebpage: store.state.proxyTwitchWebpage,
proxyUsherRequests: store.state.proxyUsherRequests,
passportLevel: store.state.passportLevel,
whitelistedChannels: store.state.whitelistedChannels,
} as Partial<State>),
"application/json;charset=utf-8"
@ -495,6 +566,13 @@ importButtonElement.addEventListener("click", async () => {
item != null ? isNormalProxyUrlAllowed(item.toString())[0] : false
);
}
if (key === "passportLevel") {
if (typeof value !== "number") {
filteredValue = DEFAULT_STATE.passportLevel;
} else {
filteredValue = Math.min(Math.max(value, 0), 2);
}
}
// @ts-ignore
store.state[key] = filteredValue;
}
@ -513,6 +591,163 @@ resetButtonElement.addEventListener("click", () => {
window.location.reload(); // Reload page to update UI.
});
adLogSendButtonElement.addEventListener("click", async () => {
const success = await sendAdLog();
if (success === null) {
return alert("No log entries to send.");
}
if (!success) {
return alert("Failed to send log.");
}
alert("Log sent successfully.");
});
adLogExportButtonElement.addEventListener("click", () => {
saveFile(
"ttv-lol-pro_ad-log.json",
JSON.stringify(store.state.adLog),
"application/json;charset=utf-8"
);
});
adLogClearButtonElement.addEventListener("click", () => {
const confirmation = confirm(
"Are you sure you want to clear the ad log? This cannot be undone."
);
if (!confirmation) return;
store.state.adLog = [];
});
twitchTabsReportButtonElement.addEventListener("click", async () => {
let report = "**Twitch Tabs Report**\n\n";
const extensionInfo = await browser.management.getSelf();
const userAgentParser = Bowser.getParser(window.navigator.userAgent);
report += `Extension: ${extensionInfo.name} v${extensionInfo.version} (${extensionInfo.installType})\n`;
report += `Browser: ${userAgentParser.getBrowserName()} ${userAgentParser.getBrowserVersion()} (${userAgentParser.getOSName()} ${userAgentParser.getOSVersion()})\n\n`;
const openedTabs = await browser.tabs.query({
url: ["https://www.twitch.tv/*", "https://m.twitch.tv/*"],
});
const detectedTabs = store.state.openedTwitchTabs;
// Print all opened tabs.
report += `Opened Twitch tabs (${openedTabs.length}):\n`;
for (const tab of openedTabs) {
report += `- ${tab.url || tab.pendingUrl} (id: ${tab.id}, windowId: ${
tab.windowId
})\n`;
}
report += "\n";
// Whitelisted tabs in `openedTabs`.
const openedWhitelistedTabs = openedTabs.filter(tab => {
const url = tab.url || tab.pendingUrl;
if (!url) return false;
const channelName = findChannelFromTwitchTvUrl(url);
const isWhitelisted = channelName
? isChannelWhitelisted(channelName)
: false;
return isWhitelisted;
});
report += `Out of the ${openedTabs.length} opened Twitch tabs, ${
openedWhitelistedTabs.length
} ${openedWhitelistedTabs.length === 1 ? "is" : "are"} whitelisted:\n`;
for (const tab of openedWhitelistedTabs) {
report += `- ${tab.url || tab.pendingUrl} (id: ${tab.id}, windowId: ${
tab.windowId
})\n`;
}
report += "\n";
// Check for missing tabs in `detectedTabs`.
const missingTabs = openedTabs.filter(
tab => !detectedTabs.some(extensionTab => extensionTab.id === tab.id)
);
if (missingTabs.length > 0) {
report += `The following Twitch tabs are missing from \`store.state.openedTwitchTabs\`:\n`;
for (const tab of missingTabs) {
report += `- ${tab.url || tab.pendingUrl} (id: ${tab.id}, windowId: ${
tab.windowId
})\n`;
}
report += "\n";
} else {
report +=
"All opened Twitch tabs are present in `store.state.openedTwitchTabs`.\n\n";
}
// Check for extra tabs in `detectedTabs`.
const extraTabs = detectedTabs.filter(
extensionTab => !openedTabs.some(tab => tab.id === extensionTab.id)
);
if (extraTabs.length > 0) {
report += `The following Twitch tabs are extra in \`store.state.openedTwitchTabs\`:\n`;
for (const tab of extraTabs) {
report += `- ${tab.url || tab.pendingUrl} (id: ${tab.id}, windowId: ${
tab.windowId
})\n`;
}
report += "\n";
} else {
report += "No extra Twitch tabs in `store.state.openedTwitchTabs`.\n\n";
}
// Whitelisted tabs in `detectedTabs`.
const detectedWhitelistedTabs = detectedTabs.filter(tab => {
const url = tab.url || tab.pendingUrl;
if (!url) return false;
const channelName = findChannelFromTwitchTvUrl(url);
const isWhitelisted = channelName
? isChannelWhitelisted(channelName)
: false;
return isWhitelisted;
});
report += `Out of the ${
detectedTabs.length
} Twitch tabs in \`store.state.openedTwitchTabs\`, ${
detectedWhitelistedTabs.length
} ${detectedWhitelistedTabs.length === 1 ? "is" : "are"} whitelisted:\n`;
for (const tab of detectedWhitelistedTabs) {
report += `- ${tab.url || tab.pendingUrl} (id: ${tab.id}, windowId: ${
tab.windowId
})\n`;
}
report += "\n";
// Should the PAC script be set?
const allTabsAreWhitelisted =
openedWhitelistedTabs.length === openedTabs.length;
const shouldSetPacScript = openedTabs.length > 0 && !allTabsAreWhitelisted;
report += `Should the PAC script be set? ${
shouldSetPacScript ? "Yes" : "No"
}\n`;
report += `Is the PAC script set? ${
store.state.chromiumProxyActive ? "Yes" : "No"
}\n`;
report += "\n";
let fixed = false;
if (shouldSetPacScript && !store.state.chromiumProxyActive) {
store.state.openedTwitchTabs = openedTabs;
updateProxySettings();
fixed = true;
report += "Fixed issue by setting the PAC script.\n";
} else if (!shouldSetPacScript && store.state.chromiumProxyActive) {
store.state.openedTwitchTabs = openedTabs;
clearProxySettings();
fixed = true;
report += "Fixed issue by unsetting the PAC script.\n";
}
saveFile("ttv-lol-pro_tabs-report.txt", report, "text/plain;charset=utf-8");
alert(
`Report saved ${
fixed ? "and issue fixed " : ""
}successfully. Please send the report to the developer (on Discord or GitHub).`
);
});
unsetPacScriptButtonElement.addEventListener("click", () => {
if (isChromium) {
clearProxySettings();

View File

@ -5,191 +5,239 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Options - TTV LOL PRO</title>
<link rel="icon" href="../images/brand/favicon.ico" />
<link rel="icon" href="../common/images/brand/favicon.ico" />
<link rel="stylesheet" href="../common/css/boilerplate.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<header>
<img src="../images/brand/icon.png" alt="Icon of TTV LOL PRO" />
<h1>Options</h1>
</header>
<div class="wrapper">
<header>
<div class="title-container">
<img
src="../common/images/brand/icon.png"
alt="Icon of TTV LOL PRO"
class="icon"
/>
<h1 class="title">Options</h1>
</div>
<div id="buttons-container">
<button id="export-button">Back up to file…</button>
<button id="import-button">Restore from file…</button>
<button id="reset-button">Reset to default settings…</button>
</div>
</header>
<main>
<!-- Proxy Usher requests -->
<section class="section">
<h2>Passport</h2>
<div id="passport-container">
<img src="../images/passport.png" alt="TTV LOL PRO passport" />
<main>
<!-- Passport -->
<section id="passport" class="section">
<h2>Passport</h2>
<div id="passport-level-container">
<img
src="../common/images/passport.png"
alt="TTV LOL PRO passport"
id="passport-level-image"
/>
<div id="passport-level-slider-container">
<input
type="range"
name="passport-level-slider"
id="passport-level-slider"
min="0"
max="2"
step="1"
list="passport-level-slider-datalist"
/>
<datalist id="passport-level-slider-datalist">
<option value="0" label="Ordinary"></option>
<option value="1" label="Official"></option>
<option value="2" label="Diplomatic"></option>
</datalist>
</div>
<details id="passport-level-proxy-usage">
<summary id="passport-level-proxy-usage-summary"></summary>
<table id="passport-level-proxy-usage-table">
<tbody>
<tr>
<td>passport.twitch.tv</td>
<td id="passport-level-proxy-usage-passport"></td>
</tr>
<tr>
<td>usher.ttvnw.net</td>
<td id="passport-level-proxy-usage-usher"></td>
</tr>
<tr>
<td>video-weaver.*.hls.ttvnw.net</td>
<td id="passport-level-proxy-usage-video-weaver"></td>
</tr>
<tr>
<td>gql.twitch.tv</td>
<td id="passport-level-proxy-usage-gql"></td>
</tr>
<tr>
<td>www.twitch.tv</td>
<td id="passport-level-proxy-usage-www"></td>
</tr>
</tbody>
</table>
</details>
<small id="passport-level-warning">
<b>WARNING:</b>
Enabling this option could open up a range of features that are
not accessible in your region, including Predictions, Prime
Subscriptions, or currency changes, among others. Please be aware
that you take full responsibility for the content passing through
your proxy or public ones. It's important to note that in certain
countries, features like Predictions might be categorized as
gambling, making them inappropriate for minors. If your country
doesn't support these features, there are legitimate reasons for
it. Stay informed and conduct online research accordingly.
</small>
</div>
<ul class="options-list">
<li>
<input
type="checkbox"
name="proxy-usher-requests-checkbox"
id="proxy-usher-requests-checkbox"
/>
<label for="proxy-usher-requests-checkbox">
Use my TTV LOL PRO passport
</label>
<br />
<small>
Browse Twitch as a TTV LOL PRO citizen! This option enables
proxying of <code>passport.twitch.tv</code> and
<code>usher.ttvnw.net</code> requests.
</small>
<br />
<small>
This option is not an on/off switch. TTV LOL PRO will still
proxy <code>video-weaver.*.hls.ttvnw.net</code> requests even if
this option is disabled.
</small>
</li>
<li>
<input
type="checkbox"
name="proxy-twitch-webpage-checkbox"
id="proxy-twitch-webpage-checkbox"
/>
<label for="proxy-twitch-webpage-checkbox">
Make the passport a
<a
href="https://en.wikipedia.org/wiki/Laissez-passer"
target="_blank"
>laissez-passer</a
>
</label>
<br />
<small>
This option enables proxying of <code>www.twitch.tv</code> and
<code>gql.twitch.tv</code> requests.
</small>
<br />
<small>
<b>WARNING:</b>
Enabling this option may unlock unavailable features in your
country, like Predictions, Prime Subscriptions, or currency
changes, among others. You assume full responsibility for the
content passing through your proxy or public ones, and note that
some features, like Predictions, might be considered gambling in
certain countries, making them illegal for minors. If your
country lacks these features, there are valid reasons for it.
Stay informed and conduct online research accordingly.
</small>
<br />
<small>
<b>
ONLY ENABLE THIS OPTION IF YOU ARE EXPERIENCING ISSUES WITH
TTV LOL PRO'S PASSPORT!
</b>
</small>
</li>
<li id="anonymous-mode-li">
<input
type="checkbox"
name="anonymous-mode-checkbox"
id="anonymous-mode-checkbox"
/>
<label for="anonymous-mode-checkbox">
Redact my passport information
</label>
<span class="tag">Recommended</span>
<label for="anonymous-mode-checkbox">Anonymous mode</label>
<br />
<small>
Watch streams as if you were logged out. This option removes
authentication headers from requests to Twitch.
Watch streams as if you were logged out. This option might help
reduce the number of "Commercial break in progress" ads.
</small>
</li>
<small><b>Expiration date:</b> 2038-01-19T03:14:07.000Z</small>
</ul>
</div>
</section>
</section>
<!-- Whitelisted channels -->
<section id="whitelisted-channels-section" class="section">
<h2>Whitelisted channels</h2>
<small>
Support your favorite content creators by whitelisting their channels.
On Chromium-based browsers, whitelisting only works when all opened
Twitch tabs are whitelisted channels.
</small>
<ul id="whitelisted-channels-list" class="store-list"></ul>
</section>
<!-- Whitelisted channels -->
<section id="whitelisted-channels" class="section">
<h2>Whitelisted channels</h2>
<small>
Support your favorite content creators by whitelisting their
channels.
</small>
<br class="chromium-only" />
<small class="chromium-only">
On Chromium-based browsers, whitelisting only works when all opened
Twitch tabs are whitelisted channels.
</small>
<ul id="whitelisted-channels-list" class="store-list"></ul>
</section>
<!-- Proxies -->
<section class="section">
<h2>Proxies</h2>
<small>
Proxies listed below must be HTTP proxies in the format
<code>hostname:port</code>. To provide authentication credentials, use
the format <code>username:password@hostname:port</code>.
</small>
<br />
<small>
IPv6 addresses must be enclosed in square brackets, for example
<code>[::1]:8080</code>.
</small>
<br />
<fieldset>
<div id="optimized-proxies-div">
<input
type="radio"
name="proxy-mode"
id="optimized"
value="optimized"
checked
/>
<label for="optimized">Proxy ad requests only</label>
<ol id="optimized-proxies-list" class="store-list" start="0"></ol>
</div>
<div>
<input type="radio" name="proxy-mode" id="normal" value="normal" />
<label for="normal">Proxy all requests</label>
<ol id="normal-proxies-list" class="store-list" start="0"></ol>
</div>
</fieldset>
<small>
Looking for other proxies? Check out the "<a
href="https://github.com/younesaassila/ttv-lol-pro/discussions/130"
target="_blank"
>List of other proxies</a
>" discussion on TTV LOL PRO's GitHub repository.
</small>
</section>
<!-- Proxies -->
<section id="proxies" class="section">
<h2>Proxies</h2>
<small>
Proxies listed below must be HTTP proxies in the format
<code>hostname:port</code>
</small>
<br />
<small>
To provide authentication credentials, use the format
<code>username:password@hostname:port</code>
</small>
<br />
<fieldset>
<div>
<input
type="radio"
name="proxy-mode"
id="optimized"
value="optimized"
checked
/>
<label for="optimized">Proxy ad requests only</label>
<ol id="optimized-proxies-list" class="store-list" start="0"></ol>
</div>
<div>
<input
type="radio"
name="proxy-mode"
id="normal"
value="normal"
/>
<label for="normal">Proxy all requests</label>
<ol id="normal-proxies-list" class="store-list" start="0"></ol>
</div>
</fieldset>
<small>
Looking for other proxies? Check out the "<a
href="https://github.com/younesaassila/ttv-lol-pro/discussions/130"
target="_blank"
>List of other proxies</a
>" discussion on TTV LOL PRO's GitHub repository.
</small>
</section>
<!-- Ad log -->
<section id="ad-log-section" class="section">
<h2>Ad log</h2>
<small>
If enabled, TTV LOL PRO will log all ads that did not get blocked for
debugging purposes. Entries are automatically removed after 7 days.
</small>
<ul class="options-list">
<li>
<input
type="checkbox"
name="ad-log-enabled-checkbox"
id="ad-log-enabled-checkbox"
/>
<label for="ad-log-enabled-checkbox">Enable ad log</label>
</li>
</ul>
<button id="ad-log-send-button" class="btn-primary">
Send ad log to developer…
</button>
<button id="ad-log-export-button">Export ad log…</button>
<button id="ad-log-clear-button">Clear ad log</button>
</section>
<!-- Ad log -->
<section id="ad-log" class="firefox-only section">
<h2>Ad log</h2>
<small>
If enabled, TTV LOL PRO will log all ads that did not get blocked
for debugging purposes. Entries are automatically removed after 7
days.
</small>
<ul class="options-list">
<li>
<input
type="checkbox"
name="ad-log-enabled-checkbox"
id="ad-log-enabled-checkbox"
/>
<label for="ad-log-enabled-checkbox">Enable ad log</label>
</li>
</ul>
<button id="ad-log-send-button" class="btn-primary">
Send ad log to developer…
</button>
<button id="ad-log-export-button">Export ad log…</button>
<button id="ad-log-clear-button">Clear ad log</button>
</section>
<hr />
<!-- Troubleshooting -->
<section id="troubleshooting" class="chromium-only section">
<h2>Troubleshooting</h2>
<br />
<button id="twitch-tabs-report-button">
Generate Twitch tabs report…
</button>
<button id="unset-pac-script-button">Unset PAC script</button>
</section>
</main>
<!-- Backup and restore -->
<section class="section">
<button id="export-button">Back up to file…</button>
<button id="import-button">Restore from file…</button>
<button id="reset-button">Reset to default settings…</button>
<button id="unset-pac-script-button">Unset PAC script</button>
</section>
</main>
<footer>
<nav>
<ul>
<li>
<a
href="https://github.com/younesaassila/ttv-lol-pro"
target="_blank"
rel="noopener noreferrer"
>Source code</a
>
</li>
<li>
<a
href="https://github.com/younesaassila/ttv-lol-pro/releases"
target="_blank"
rel="noopener noreferrer"
>Changelog</a
>
</li>
<li>
<a
href="https://github.com/younesaassila/ttv-lol-pro/blob/v2/PRIVACY.md"
target="_blank"
rel="noopener noreferrer"
>Privacy policy</a
>
</li>
</ul>
</nav>
<small id="version"></small>
</footer>
</div>
<script type="module" src="./options.ts"></script>
</body>

View File

@ -1,14 +1,17 @@
@font-face {
src: url("../fonts/Inter-VariableFont_slnt\,wght.ttf");
src: url("../common/fonts/Inter-VariableFont_slnt\,wght.ttf");
font-family: "Inter";
}
:root {
--wrapper-width: 1100px;
--font-primary: "Inter", "Roobert", "Helvetica Neue", Helvetica, Arial,
sans-serif;
--brand-color: #aa51b8;
--ui-background-color: #151619;
--wrapper-box-shadow-color: #0c0c0e;
--wrapper-background-color: #151619;
--body-background-color: #0e0f11;
--text-primary: #e4e6e7;
--text-secondary: #8d9296;
@ -18,6 +21,7 @@
--input-border-color: #353840;
--input-text-primary: #c3c4ca;
--input-text-secondary: #7a8085;
--input-max-width: 450px;
--button-background-color: #353840;
--button-background-color-hover: #464953;
@ -26,20 +30,20 @@
--link: #be68ce;
--link-hover: #cc88d8;
--header-height: 2.5rem;
--logo-height: 2.5rem;
--low-color: #06c157;
--low-bg-color: #1e2421;
--medium-color: #f9c643;
--medium-bg-color: #24221e;
--high-color: #f93e3e;
--high-bg-color: #241e1e;
}
body {
margin: 1.5rem;
background-color: var(--ui-background-color);
color: var(--text-primary);
accent-color: var(--brand-color);
font-size: 100%;
font-family: var(--font-primary);
}
main {
margin-left: 1rem;
*,
*::before,
*::after {
box-sizing: border-box;
}
::-moz-selection,
@ -48,13 +52,79 @@ main {
color: #ffffff;
}
.font-weight-bold {
font-weight: bold;
body {
margin: 0;
background-image: url("../common/images/options_bg.png");
background-repeat: repeat;
background-color: var(--body-background-color);
color: var(--text-primary);
accent-color: var(--brand-color);
font-size: 100%;
font-family: var(--font-primary);
}
fieldset {
margin-top: 1rem;
border: 0;
.wrapper {
position: relative;
left: 50%;
width: min(100%, var(--wrapper-width));
transform: translateX(-50%);
background-color: var(--wrapper-background-color);
box-shadow: 0 0 32px var(--wrapper-box-shadow-color);
}
main {
padding: 2rem;
}
header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 1.5rem;
gap: 1rem;
border-bottom: 1px solid var(--input-border-color);
background-color: var(--wrapper-background-color);
}
header > .title-container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 1rem;
}
header > .title-container > .icon {
width: var(--logo-height);
height: var(--logo-height);
}
header > .title-container > .title {
margin: 0;
font-size: 1.75rem;
}
header > #buttons-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem;
border-top: 1px solid var(--input-border-color);
font-size: 9pt;
}
footer > nav > ul {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
margin: 0;
padding: 0;
gap: 1.5rem;
list-style-type: none;
}
a,
@ -63,7 +133,6 @@ a:visited {
text-decoration: none;
transition: color 100ms ease-in-out;
}
a:hover,
a:visited:hover {
color: var(--link-hover);
@ -79,12 +148,10 @@ select {
color: var(--input-text-primary);
vertical-align: middle;
}
input[type="text"]:disabled {
background-color: var(--input-background-color-disabled);
color: var(--input-text-secondary);
}
input[type="text"]::placeholder {
font-style: italic;
}
@ -100,7 +167,6 @@ button {
cursor: pointer;
transition: background-color 100ms ease-in-out;
}
input[type="button"]:hover,
button:hover {
background-color: var(--button-background-color-hover);
@ -110,7 +176,6 @@ button:hover {
background-color: var(--brand-color);
color: #ffffff;
}
.btn-primary:hover {
background-color: var(--link-hover);
}
@ -119,32 +184,34 @@ input[type="checkbox"]:disabled + label {
opacity: 0.7;
}
fieldset {
margin-top: 1rem;
border: 0;
}
small {
color: var(--text-secondary);
font-size: 9pt;
}
hr {
margin: 2.5rem 0;
border: 0;
border-top: 1px solid var(--input-border-color);
}
header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
height: var(--header-height);
gap: 1rem;
}
header > img {
max-height: 100%;
}
.section {
margin-top: 1.5rem;
margin: 0 0 3rem 0;
}
.section:last-child {
margin-bottom: 0;
}
.section > h2 {
margin-top: 0;
margin-bottom: 0.25rem;
font-size: 1.15rem;
font-size: 1.3rem;
}
.tag {
@ -158,13 +225,9 @@ header > img {
text-transform: uppercase;
}
small {
color: var(--text-secondary);
font-size: 9pt;
}
.store-list > li > input {
min-width: 400px;
width: 100%;
max-width: var(--input-max-width);
margin-bottom: 0.25rem;
}
@ -177,23 +240,162 @@ li.hide-marker::marker {
margin-bottom: 0;
list-style-type: none;
}
.options-list > li {
position: relative;
margin-bottom: 1rem;
}
.options-list > li > input[type="checkbox"] {
position: absolute;
left: -1.6rem;
margin-top: 0.3rem;
}
#passport-container {
display: flex;
#passport-level-container {
display: grid;
grid-template-rows: auto auto auto;
grid-template-columns: auto 1fr;
grid-template-areas:
"image slider"
". usage"
". warning";
column-gap: 1.25rem;
row-gap: 0;
align-items: center;
margin: 1rem 0 1.5rem 0;
}
#passport-container > img {
height: 80px;
margin-top: 1rem;
#passport-level-image {
grid-area: image;
height: 55px;
}
#passport-level-slider-container {
grid-area: slider;
}
#passport-level-slider {
width: 100%;
max-width: var(--input-max-width);
margin: 0;
}
#passport-level-slider-datalist {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
width: 100%;
max-width: var(--input-max-width);
text-align: center;
}
#passport-level-slider-datalist > option:first-child {
text-align: left;
}
#passport-level-slider-datalist > option:last-child {
text-align: right;
}
#passport-level-proxy-usage {
grid-area: usage;
width: 100%;
max-width: var(--input-max-width);
margin-top: 0.5rem;
padding: 0;
overflow: hidden;
border: 1px solid var(--input-border-color);
border-radius: 18px;
background-color: var(--input-background-color);
}
#passport-level-proxy-usage[data-usage="low"] {
border-color: var(--low-color);
background-color: var(--low-bg-color);
}
#passport-level-proxy-usage[data-usage="medium"] {
border-color: var(--medium-color);
background-color: var(--medium-bg-color);
}
#passport-level-proxy-usage[data-usage="high"] {
border-color: var(--high-color);
background-color: var(--high-bg-color);
}
#passport-level-proxy-usage-summary {
margin: 0;
padding: 0.5rem;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 100ms ease-in-out, color 100ms ease-in-out;
}
#passport-level-proxy-usage-summary::marker {
content: none;
}
#passport-level-proxy-usage-summary::after {
display: block;
float: right;
transform: translateY(-15%) rotate(-45deg);
content: "∟";
text-align: right;
}
#passport-level-proxy-usage[open] #passport-level-proxy-usage-summary::after {
display: block;
float: right;
transform: translateY(30%) rotate(135deg);
content: "∟";
text-align: right;
}
#passport-level-proxy-usage[data-usage="low"]
#passport-level-proxy-usage-summary:hover {
background-color: var(--low-color);
color: #000000;
}
#passport-level-proxy-usage[data-usage="medium"]
#passport-level-proxy-usage-summary:hover {
background-color: var(--medium-color);
color: #000000;
}
#passport-level-proxy-usage[data-usage="high"]
#passport-level-proxy-usage-summary:hover {
background-color: var(--high-color);
color: #000000;
}
#passport-level-proxy-usage-table {
width: 100%;
margin: 0;
padding: 0.5rem;
font-size: 0.7rem;
}
#passport-level-proxy-usage-table > tbody > tr > td:nth-child(2) {
color: var(--text-secondary);
text-align: right;
}
#passport-level-warning {
display: none;
grid-area: warning;
margin-top: 0.75rem;
}
@media screen and (max-width: 800px) {
header {
flex-direction: column;
}
}
@media screen and (max-width: 600px) {
main {
padding: 1.25rem;
}
header > #buttons-container {
flex-direction: column;
width: 100%;
max-width: 400px;
gap: 0.25rem;
}
header > #buttons-container > button {
width: 100%;
}
footer > nav > ul {
gap: 0.5rem;
}
}

View File

@ -1,36 +1,94 @@
import * as m3u8Parser from "m3u8-parser";
import acceptFlag from "../common/ts/acceptFlag";
import findChannelFromTwitchTvUrl from "../common/ts/findChannelFromTwitchTvUrl";
import findChannelFromUsherUrl from "../common/ts/findChannelFromUsherUrl";
import generateRandomString from "../common/ts/generateRandomString";
import getHostFromUrl from "../common/ts/getHostFromUrl";
import isRequestTypeProxied from "../common/ts/isRequestTypeProxied";
import {
twitchGqlHostRegex,
usherHostRegex,
videoWeaverHostRegex,
videoWeaverUrlRegex,
} from "../common/ts/regexes";
import { State } from "../store/types";
import { MessageType, ProxyRequestType } from "../types";
import type { PageState, PlaybackAccessToken, UsherManifest } from "./types";
const IS_DEVELOPMENT = process.env.NODE_ENV == "development";
const NATIVE_FETCH = self.fetch;
const IS_CHROMIUM = !!self.chrome;
export interface FetchOptions {
scope: "page" | "worker";
shouldWaitForStore: boolean;
state?: State;
}
export function getFetch(pageState: PageState): typeof fetch {
let usherManifests: UsherManifest[] = [];
let videoWeaverUrlsProxiedCount = new Map<string, number>(); // Used to count how many times each Video Weaver URL was proxied.
export function getFetch(options: FetchOptions): typeof fetch {
// TODO: Clear variables on navigation.
const knownVideoWeaverUrls = new Set<string>();
const videoWeaverUrlsToFlag = new Map<string, number>(); // Video Weaver URLs to flag -> number of times flagged.
const videoWeaverUrlsToIgnore = new Set<string>(); // No response check.
let cachedPlaybackTokenRequestHeaders: Map<string, string> | null = null; // Cached by page script.
let cachedPlaybackTokenRequestBody: string | null = null; // Cached by page script.
let cachedUsherRequestUrl: string | null = null; // Cached by worker script.
if (options.shouldWaitForStore) {
setTimeout(() => {
options.shouldWaitForStore = false;
}, 5000);
// Listen for NewPlaybackAccessToken messages from the worker script.
if (pageState.scope === "page") {
self.addEventListener("message", async event => {
if (event.data?.type !== MessageType.PageScriptMessage) return;
const message = event.data?.message;
if (!message) return;
switch (message.type) {
case MessageType.NewPlaybackAccessToken:
await waitForStore(pageState);
const newPlaybackAccessToken =
await fetchReplacementPlaybackAccessToken(
pageState,
cachedPlaybackTokenRequestHeaders,
cachedPlaybackTokenRequestBody
);
const message = {
type: MessageType.NewPlaybackAccessTokenResponse,
newPlaybackAccessToken,
};
pageState.twitchWorker?.postMessage({
type: MessageType.WorkerScriptMessage,
message,
});
break;
}
});
}
// Listen for ClearStats messages from the page script.
self.addEventListener("message", event => {
if (
event.data?.type !== MessageType.PageScriptMessage &&
event.data?.type !== MessageType.WorkerScriptMessage
) {
return;
}
const message = event.data?.message;
if (!message) return;
switch (message.type) {
case MessageType.ClearStats:
console.log("[TTV LOL PRO] Cleared stats (getFetch).");
usherManifests = [];
cachedPlaybackTokenRequestHeaders = null;
cachedPlaybackTokenRequestBody = null;
cachedUsherRequestUrl = null;
break;
}
});
// // Test Video Weaver URL replacement.
// if (IS_DEVELOPMENT && pageState.scope === "worker") {
// setTimeout(async () => {
// await waitForStore(pageState);
// updateVideoWeaverReplacementMap(
// pageState,
// cachedUsherRequestUrl,
// usherManifests[usherManifests.length - 1]
// );
// }, 30000);
// }
return async function fetch(
input: RequestInfo | URL,
init?: RequestInit
@ -52,6 +110,10 @@ export function getFetch(options: FetchOptions): typeof fetch {
const host = getHostFromUrl(url);
const headersMap = getHeadersMap(input, init);
let isFlaggedRequest = false; // Whether or not the request should be proxied.
let request: Request | null = null; // Request can be overwritten.
let requestType: ProxyRequestType | null = null;
// Reading the request body can be expensive, so we only do it if we need to.
let requestBody: string | null | undefined = undefined;
const readRequestBody = async (): Promise<string | null> => {
@ -62,107 +124,280 @@ export function getFetch(options: FetchOptions): typeof fetch {
//#region Requests
// Twitch GraphQL requests.
if (host != null && twitchGqlHostRegex.test(host)) {
requestBody = await readRequestBody();
// Integrity requests.
if (url === "https://gql.twitch.tv/integrity") {
console.debug(
"[TTV LOL PRO] 🥅 Caught GraphQL integrity request. Flagging…"
);
flagRequest(headersMap);
}
// Requests with Client-Integrity header.
const integrityHeader = getHeaderFromMap(headersMap, "Client-Integrity");
if (integrityHeader != null) {
console.debug(
"[TTV LOL PRO] 🥅 Caught GraphQL request with Client-Integrity header. Flagging…"
);
flagRequest(headersMap);
}
// PlaybackAccessToken requests.
if (
requestBody != null &&
requestBody.includes("PlaybackAccessToken_Template")
) {
console.debug(
"[TTV LOL PRO] 🥅 Caught GraphQL PlaybackAccessToken_Template request. Flagging…"
);
graphql: if (host != null && twitchGqlHostRegex.test(host)) {
requestType = ProxyRequestType.GraphQL;
while (options.shouldWaitForStore) await sleep(100);
//#region GraphQL integrity requests.
const integrityHeader = getHeaderFromMap(headersMap, "Client-Integrity");
const isIntegrityRequest = url === "https://gql.twitch.tv/integrity";
const isIntegrityHeaderRequest = integrityHeader != null;
if (isIntegrityRequest || isIntegrityHeaderRequest) {
await waitForStore(pageState);
const shouldFlagRequest = isRequestTypeProxied(
ProxyRequestType.GraphQLIntegrity,
{
isChromium: pageState.isChromium,
optimizedProxiesEnabled:
pageState.state?.optimizedProxiesEnabled ?? true,
passportLevel: pageState.state?.passportLevel ?? 0,
}
);
if (shouldFlagRequest) {
if (isIntegrityRequest) {
console.debug("[TTV LOL PRO] Flagging GraphQL integrity request…");
isFlaggedRequest = true;
} else if (isIntegrityHeaderRequest) {
console.debug(
"[TTV LOL PRO] Flagging GraphQL request with Client-Integrity header…"
);
isFlaggedRequest = true;
}
}
break graphql;
}
//#endregion
//#region GraphQL PlaybackAccessToken requests.
requestBody ??= await readRequestBody();
if (requestBody != null && requestBody.includes("PlaybackAccessToken")) {
// Cache the request headers and body for later use.
cachedPlaybackTokenRequestHeaders = headersMap;
cachedPlaybackTokenRequestBody = requestBody;
// Check if this is a livestream and if it's whitelisted.
let graphQlBody = null;
try {
graphQlBody = JSON.parse(requestBody);
} catch {}
} catch (error) {
console.error(
"[TTV LOL PRO] Failed to parse GraphQL request body:",
error
);
}
await waitForStore(pageState);
const channelName = graphQlBody?.variables?.login as string | undefined;
const whitelistedChannelsLower = options.state?.whitelistedChannels.map(
channel => channel.toLowerCase()
);
const isLivestream = graphQlBody?.variables?.isLive as
| boolean
| undefined;
const whitelistedChannelsLower =
pageState.state?.whitelistedChannels.map(channel =>
channel.toLowerCase()
);
const isWhitelisted =
channelName != null &&
whitelistedChannelsLower != null &&
whitelistedChannelsLower.includes(channelName.toLowerCase());
if (options.state?.anonymousMode === true) {
if (!isWhitelisted) {
console.log("[TTV LOL PRO] 🕵️ Anonymous mode is enabled.");
setHeaderToMap(headersMap, "Authorization", "undefined");
removeHeaderFromMap(headersMap, "Client-Session-Id");
removeHeaderFromMap(headersMap, "Client-Version");
setHeaderToMap(headersMap, "Device-ID", generateRandomString(32));
removeHeaderFromMap(headersMap, "Sec-GPC");
removeHeaderFromMap(headersMap, "X-Device-Id");
} else {
// Check if we should flag this request.
const shouldFlagRequest = isRequestTypeProxied(
ProxyRequestType.GraphQLToken,
{
isChromium: pageState.isChromium,
optimizedProxiesEnabled:
pageState.state?.optimizedProxiesEnabled ?? true,
passportLevel: pageState.state?.passportLevel ?? 0,
}
);
if (!isLivestream || isWhitelisted) {
console.log(
"[TTV LOL PRO] Not flagging PlaybackAccessToken request: not a livestream or is whitelisted."
);
break graphql;
}
const isTemplateRequest = requestBody.includes(
"PlaybackAccessToken_Template"
);
const areIntegrityRequestsProxied = isRequestTypeProxied(
ProxyRequestType.GraphQLIntegrity,
{
isChromium: pageState.isChromium,
optimizedProxiesEnabled:
pageState.state?.optimizedProxiesEnabled ?? true,
passportLevel: pageState.state?.passportLevel ?? 0,
}
);
// "PlaybackAccessToken" requests contain a Client-Integrity header.
// Thus, if integrity requests are not proxied, we can't proxy this request.
const willFailIntegrityCheckIfProxied =
!isTemplateRequest && !areIntegrityRequestsProxied;
const shouldOverrideRequest =
pageState.state?.anonymousMode === true ||
willFailIntegrityCheckIfProxied;
if (shouldOverrideRequest) {
const newRequest = await getDefaultPlaybackAccessTokenRequest(
channelName,
pageState.state?.anonymousMode === true
);
if (newRequest) {
console.log(
"[TTV LOL PRO] 🕵️✋ Anonymous mode is enabled but channel is whitelisted."
"[TTV LOL PRO] Overriding PlaybackAccessToken request…"
);
request = newRequest;
// Since this is a template request, whether or not integrity requests are proxied doesn't matter.
} else {
console.error(
"[TTV LOL PRO] Failed to override PlaybackAccessToken request."
);
}
}
flagRequest(headersMap);
} else if (
requestBody != null &&
requestBody.includes("PlaybackAccessToken")
) {
console.debug(
"[TTV LOL PRO] 🥅 Caught GraphQL PlaybackAccessToken request. Flagging…"
);
flagRequest(headersMap);
// Notice that if anonymous mode fails, we still flag the request to avoid ads.
if (shouldFlagRequest && !willFailIntegrityCheckIfProxied) {
console.log("[TTV LOL PRO] Flagging PlaybackAccessToken request…");
isFlaggedRequest = true;
}
break graphql;
}
//#endregion
}
// Usher requests.
if (host != null && usherHostRegex.test(host)) {
console.debug("[TTV LOL PRO] 🥅 Caught Usher request.");
}
// Video Weaver requests.
if (host != null && videoWeaverHostRegex.test(host)) {
const isIgnoredUrl = videoWeaverUrlsToIgnore.has(url);
const isNewUrl = !knownVideoWeaverUrls.has(url);
const isFlaggedUrl = videoWeaverUrlsToFlag.has(url);
if (!isIgnoredUrl && (isNewUrl || isFlaggedUrl)) {
// Twitch Usher requests.
usher: if (host != null && usherHostRegex.test(host)) {
cachedUsherRequestUrl = url; // Cache the URL for later use.
requestType = ProxyRequestType.Usher;
await waitForStore(pageState);
const channelName = findChannelFromUsherUrl(url);
const isLivestream = !url.includes("/vod/");
const whitelistedChannelsLower = pageState.state?.whitelistedChannels.map(
channel => channel.toLowerCase()
);
const isWhitelisted =
channelName != null &&
whitelistedChannelsLower != null &&
whitelistedChannelsLower.includes(channelName.toLowerCase());
const shouldFlagRequest = isRequestTypeProxied(ProxyRequestType.Usher, {
isChromium: pageState.isChromium,
optimizedProxiesEnabled:
pageState.state?.optimizedProxiesEnabled ?? true,
passportLevel: pageState.state?.passportLevel ?? 0,
});
if (!isLivestream || isWhitelisted) {
console.log(
`[TTV LOL PRO] 🥅 Caught ${
isNewUrl
? "first request to Video Weaver URL"
: "Video Weaver request to flag"
}. Flagging`
"[TTV LOL PRO] Not flagging Usher request: not a livestream or is whitelisted."
);
flagRequest(headersMap);
videoWeaverUrlsToFlag.set(
url,
(videoWeaverUrlsToFlag.get(url) ?? 0) + 1
);
if (isNewUrl) knownVideoWeaverUrls.add(url);
break usher;
}
if (shouldFlagRequest) {
console.debug("[TTV LOL PRO] Flagging Usher request…");
isFlaggedRequest = true;
}
}
// Twitch Video Weaver requests.
weaver: if (host != null && videoWeaverHostRegex.test(host)) {
requestType = ProxyRequestType.VideoWeaver;
//#region Video Weaver requests.
const manifest = usherManifests.find(manifest =>
[...manifest.assignedMap.values()].includes(url)
);
if (manifest == null) {
console.warn(
"[TTV LOL PRO] No associated Usher manifest found for Video Weaver request."
);
}
await waitForStore(pageState);
const channelName =
manifest?.channelName ?? findChannelFromTwitchTvUrl(location.href);
const whitelistedChannelsLower = pageState.state?.whitelistedChannels.map(
channel => channel.toLowerCase()
);
const isWhitelisted =
channelName != null &&
whitelistedChannelsLower != null &&
whitelistedChannelsLower.includes(channelName.toLowerCase());
const shouldFlagRequest = isRequestTypeProxied(
ProxyRequestType.VideoWeaver,
{
isChromium: pageState.isChromium,
optimizedProxiesEnabled:
pageState.state?.optimizedProxiesEnabled ?? true,
passportLevel: pageState.state?.passportLevel ?? 0,
}
);
if (isWhitelisted) {
if (IS_DEVELOPMENT) {
console.debug(
"[TTV LOL PRO] Not flagging Video Weaver request: is whitelisted."
);
}
break weaver;
}
// Check if we should replace the Video Weaver URL.
let videoWeaverUrl = url;
if (manifest?.replacementMap != null) {
const videoQuality = [...manifest.assignedMap].find(
([, url]) => url === videoWeaverUrl
)?.[0];
if (videoQuality != null && manifest.replacementMap.has(videoQuality)) {
videoWeaverUrl = manifest.replacementMap.get(videoQuality)!;
if (IS_DEVELOPMENT) {
console.debug(
`[TTV LOL PRO] Replaced Video Weaver URL '${url}' with '${videoWeaverUrl}'.`
);
}
} else if (manifest.replacementMap.size > 0) {
videoWeaverUrl = [...manifest.replacementMap.values()][0];
console.warn(
`[TTV LOL PRO] Replacement Video Weaver URL not found for '${url}'. Using first replacement URL '${videoWeaverUrl}'.`
);
} else {
console.error(
`[TTV LOL PRO] Replacement Video Weaver URL not found for '${url}'.`
);
}
}
// Flag first request to each Video Weaver URL.
const proxiedCount = videoWeaverUrlsProxiedCount.get(videoWeaverUrl) ?? 0;
if (shouldFlagRequest && proxiedCount < 1) {
videoWeaverUrlsProxiedCount.set(videoWeaverUrl, proxiedCount + 1);
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules/PluralRules#using_options
const pr = new Intl.PluralRules("en-US", { type: "ordinal" });
const suffixes = new Map([
["one", "st"],
["two", "nd"],
["few", "rd"],
["other", "th"],
]);
const formatOrdinals = (n: number) => {
const rule = pr.select(n);
const suffix = suffixes.get(rule);
return `${n}${suffix}`;
};
console.log(
`[TTV LOL PRO] Flagging ${formatOrdinals(
proxiedCount + 1
)} request to Video Weaver URL '${videoWeaverUrl}'`
);
isFlaggedRequest = true;
}
if (videoWeaverUrl !== url) {
request ??= new Request(videoWeaverUrl, {
...init,
});
}
//#endregion
}
//#endregion
const response = await NATIVE_FETCH(input, {
request ??= new Request(input, {
...init,
headers: Object.fromEntries(headersMap),
});
if (isFlaggedRequest) {
await waitForStore(pageState);
request = await flagRequest(request, requestType!, pageState);
}
const response = await NATIVE_FETCH(request);
if (isFlaggedRequest) {
flagRequestCleanup(requestType!, pageState);
}
// Reading the response body can be expensive, so we only do it if we need to.
let responseBody: string | undefined = undefined;
@ -174,58 +409,86 @@ export function getFetch(options: FetchOptions): typeof fetch {
//#region Responses
// Usher responses.
if (host != null && usherHostRegex.test(host)) {
responseBody = await readResponseBody();
console.debug("[TTV LOL PRO] 🥅 Caught Usher response.");
const videoWeaverUrls = responseBody
.split("\n")
.filter(line => videoWeaverUrlRegex.test(line));
// Twitch Usher responses.
if (host != null && usherHostRegex.test(host) && response.status < 400) {
responseBody ??= await readResponseBody();
const channelName = findChannelFromUsherUrl(url);
const assignedMap = parseUsherManifest(responseBody);
if (assignedMap != null) {
console.debug(
"[TTV LOL PRO] Received Usher response:",
Object.fromEntries(assignedMap)
);
usherManifests.push({
channelName,
assignedMap: assignedMap,
replacementMap: null,
consecutiveMidrollResponses: 0,
});
} else {
console.debug("[TTV LOL PRO] Received Usher response.");
}
// Send Video Weaver URLs to content script.
sendMessageToContentScript(options.scope, {
type: "UsherResponse",
channel: findChannelFromUsherUrl(url),
const videoWeaverUrls = [...(assignedMap?.values() ?? [])];
videoWeaverUrls.forEach(url => videoWeaverUrlsProxiedCount.delete(url)); // Shouldn't be necessary, but just in case.
pageState.sendMessageToContentScript({
type: MessageType.UsherResponse,
channel: channelName,
videoWeaverUrls,
proxyCountry:
/USER-COUNTRY="([A-Z]+)"/i.exec(responseBody)?.[1] || null,
/USER-COUNTRY="([A-Z]+)"/i.exec(responseBody)?.[1] || undefined,
});
// Remove all Video Weaver URLs from known URLs.
videoWeaverUrls.forEach(url => knownVideoWeaverUrls.delete(url));
}
// Video Weaver responses.
if (host != null && videoWeaverHostRegex.test(host)) {
responseBody = await readResponseBody();
// Check if response contains ad.
if (responseBody.includes("stitched-ad")) {
console.log(
"[TTV LOL PRO] 🥅 Caught Video Weaver response containing ad."
// Twitch Video Weaver responses.
if (
host != null &&
videoWeaverHostRegex.test(host) &&
response.status < 400
) {
const manifest = usherManifests.find(manifest =>
[...manifest.assignedMap.values()].includes(url)
);
if (manifest == null) {
console.warn(
"[TTV LOL PRO] No associated Usher manifest found for Video Weaver response."
);
if (videoWeaverUrlsToIgnore.has(url)) return response;
if (!videoWeaverUrlsToFlag.has(url)) {
// Let's proxy the next request for this URL, 2 attempts left.
videoWeaverUrlsToFlag.set(url, 0);
cancelRequest();
}
// FIXME: This workaround doesn't work. Let's find another way.
// 0: First attempt, not proxied, cancelled.
// 1: Second attempt, proxied, cancelled.
// 2: Third attempt, proxied, last attempt by Twitch client.
// If the third attempt contains an ad, we have to let it through.
const isCancellable = videoWeaverUrlsToFlag.get(url)! < 2;
if (isCancellable) {
cancelRequest();
} else {
console.error(
"[TTV LOL PRO] ❌ Could not cancel Video Weaver response containing ad. All attempts used."
return response;
}
// Check if response contains midroll ad.
responseBody ??= await readResponseBody();
if (
responseBody.includes("stitched-ad") &&
responseBody.toLowerCase().includes("midroll")
) {
console.log("[TTV LOL PRO] Midroll ad detected.");
manifest.consecutiveMidrollResponses += 1;
await waitForStore(pageState);
const whitelistedChannelsLower =
pageState.state?.whitelistedChannels.map(channel =>
channel.toLowerCase()
);
videoWeaverUrlsToFlag.delete(url); // Clear attempts.
videoWeaverUrlsToIgnore.add(url); // Ignore this URL, there's nothing we can do.
const isWhitelisted =
manifest.channelName != null &&
whitelistedChannelsLower != null &&
whitelistedChannelsLower.includes(manifest.channelName.toLowerCase());
if (
pageState.state?.optimizedProxiesEnabled === true &&
manifest.consecutiveMidrollResponses <= 2 && // Avoid infinite loop.
!isWhitelisted
) {
const success = await updateVideoWeaverReplacementMap(
pageState,
cachedUsherRequestUrl,
manifest
);
if (success) cancelRequest();
}
manifest.replacementMap = null;
} else {
// No ad, remove from flagged list.
videoWeaverUrlsToFlag.delete(url);
videoWeaverUrlsToIgnore.delete(url);
// No ad, clear attempts.
manifest.consecutiveMidrollResponses = 0;
}
}
@ -237,7 +500,8 @@ export function getFetch(options: FetchOptions): typeof fetch {
/**
* Converts a HeadersInit to a map.
* @param headers
* @param input
* @param init
* @returns
*/
function getHeadersMap(
@ -257,7 +521,8 @@ function getHeadersMap(
/**
* Converts a BodyInit to a string.
* @param body
* @param input
* @param init
* @returns
*/
async function getRequestBodyText(
@ -316,32 +581,334 @@ function removeHeaderFromMap(headersMap: Map<string, string>, name: string) {
}
}
function sendMessageToContentScript(scope: "page" | "worker", message: any) {
if (scope === "page") {
self.postMessage(message);
async function waitForStore(pageState: PageState) {
if (pageState.state != null) return;
try {
const message =
await pageState.sendMessageToContentScriptAndWaitForResponse(
pageState.scope,
{
type: MessageType.GetStoreState,
},
MessageType.GetStoreStateResponse
);
pageState.state = message.state;
} catch (error) {
console.error("[TTV LOL PRO] Failed to get store state:", error);
}
}
async function flagRequest(
request: Request,
requestType: ProxyRequestType,
pageState: PageState
): Promise<Request> {
if (pageState.isChromium) {
if (!pageState.state?.optimizedProxiesEnabled) return request;
try {
await pageState.sendMessageToContentScriptAndWaitForResponse(
pageState.scope,
{
type: MessageType.EnableFullMode,
timestamp: Date.now(),
requestType,
},
MessageType.EnableFullModeResponse
);
} catch (error) {
console.error("[TTV LOL PRO] Failed to flag request:", error);
}
return request;
} else {
self.postMessage({
type: "ContentScriptMessage",
message,
// Change the Accept header to include the flag.
const headersMap = getHeadersMap(request);
const accept = getHeaderFromMap(headersMap, "Accept");
if (accept != null && accept.includes(acceptFlag)) return request;
setHeaderToMap(headersMap, "Accept", `${accept || ""}${acceptFlag}`);
return new Request(request, {
headers: Object.fromEntries(headersMap),
});
}
}
function flagRequest(headersMap: Map<string, string>) {
if (IS_CHROMIUM) {
console.debug(
"[TTV LOL PRO] 🚩 Request flagging is not supported on Chromium. Ignoring…"
);
return;
function flagRequestCleanup(
requestType: ProxyRequestType,
pageState: PageState
) {
if (pageState.isChromium && pageState.state?.optimizedProxiesEnabled) {
pageState.sendMessageToContentScript({
type: MessageType.DisableFullMode,
timestamp: Date.now(),
requestType,
});
}
const accept = getHeaderFromMap(headersMap, "Accept");
setHeaderToMap(headersMap, "Accept", `${accept || ""}${acceptFlag}`);
}
function cancelRequest(): never {
throw new Error();
}
async function sleep(ms: number) {
async function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
//#region Video Weaver URL replacement
/**
* Returns a PlaybackAccessToken request that can be used when Twitch doesn't send one.
* @param channel
* @param anonymousMode
* @returns
*/
async function getDefaultPlaybackAccessTokenRequest(
channel: string | null = null,
anonymousMode: boolean = false
): Promise<Request | null> {
// We can use `location.href` because we're in the page script.
const channelName = channel ?? findChannelFromTwitchTvUrl(location.href);
if (!channelName) return null;
const isVod = /^\d+$/.test(channelName); // VODs have numeric IDs.
const cookieMap = new Map<string, string>(
document.cookie
.split(";")
.map(cookie => cookie.trim().split("="))
.map(([name, value]) => [name, decodeURIComponent(value)])
);
const headersMap = new Map<string, string>([
[
"Authorization",
cookieMap.has("auth-token") && !anonymousMode
? `OAuth ${cookieMap.get("auth-token")}`
: "undefined",
],
["Client-ID", "kimne78kx3ncx6brgo4mv6wki5h1ko"],
["Device-ID", generateRandomString(32)],
]);
return new Request("https://gql.twitch.tv/gql", {
method: "POST",
headers: Object.fromEntries(headersMap),
body: JSON.stringify({
operationName: "PlaybackAccessToken_Template",
query:
'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature authorization { isForbidden forbiddenReasonCode } __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}',
variables: {
isLive: !isVod,
login: isVod ? "" : channelName,
isVod: isVod,
vodID: isVod ? channelName : "",
playerType: "site",
},
}),
});
}
/**
* Fetches a new PlaybackAccessToken from Twitch.
* @param pageState
* @param cachedPlaybackTokenRequestHeaders
* @param cachedPlaybackTokenRequestBody
* @returns
*/
async function fetchReplacementPlaybackAccessToken(
pageState: PageState,
cachedPlaybackTokenRequestHeaders: Map<string, string> | null,
cachedPlaybackTokenRequestBody: string | null
): Promise<PlaybackAccessToken | null> {
// Not using the cached request because we'd need to check if integrity requests are proxied.
try {
let request = await getDefaultPlaybackAccessTokenRequest(
null,
pageState.state?.anonymousMode === true
);
if (request == null) return null;
const isFlaggedRequest = isRequestTypeProxied(
ProxyRequestType.GraphQLToken,
{
isChromium: pageState.isChromium,
optimizedProxiesEnabled:
pageState.state?.optimizedProxiesEnabled ?? true,
passportLevel: pageState.state?.passportLevel ?? 0,
}
);
if (isFlaggedRequest) {
request = await flagRequest(request, ProxyRequestType.GraphQL, pageState);
}
const response = await NATIVE_FETCH(request);
if (isFlaggedRequest) {
flagRequestCleanup(ProxyRequestType.GraphQL, pageState);
}
const json = await response.json();
const newPlaybackAccessToken = json?.data?.streamPlaybackAccessToken;
if (newPlaybackAccessToken == null) return null;
return newPlaybackAccessToken;
} catch {
return null;
}
}
/**
* Returns a new Usher URL with the new playback access token.
* @param cachedUsherRequestUrl
* @param playbackAccessToken
* @returns
*/
function getReplacementUsherUrl(
cachedUsherRequestUrl: string | null,
playbackAccessToken: PlaybackAccessToken
): string | null {
if (cachedUsherRequestUrl == null) return null; // Very unlikely.
try {
const newUsherUrl = new URL(cachedUsherRequestUrl);
newUsherUrl.searchParams.delete("acmb");
newUsherUrl.searchParams.set("play_session_id", generateRandomString(32));
newUsherUrl.searchParams.set("sig", playbackAccessToken.signature);
newUsherUrl.searchParams.set("token", playbackAccessToken.value);
return newUsherUrl.toString();
} catch {
return null;
}
}
/**
* Fetches a new Usher manifest from Twitch.
* @param pageState
* @param cachedUsherRequestUrl
* @param playbackAccessToken
* @returns
*/
async function fetchReplacementUsherManifest(
pageState: PageState,
cachedUsherRequestUrl: string | null,
playbackAccessToken: PlaybackAccessToken
): Promise<string | null> {
if (cachedUsherRequestUrl == null) return null; // Very unlikely.
try {
const newUsherUrl = getReplacementUsherUrl(
cachedUsherRequestUrl,
playbackAccessToken
);
if (newUsherUrl == null) return null;
let request = new Request(newUsherUrl);
const isFlaggedRequest = isRequestTypeProxied(ProxyRequestType.Usher, {
isChromium: pageState.isChromium,
optimizedProxiesEnabled: pageState.state?.optimizedProxiesEnabled ?? true,
passportLevel: pageState.state?.passportLevel ?? 0,
});
if (isFlaggedRequest) {
request = await flagRequest(request, ProxyRequestType.Usher, pageState);
}
const response = await NATIVE_FETCH(request);
if (isFlaggedRequest) {
flagRequestCleanup(ProxyRequestType.Usher, pageState);
}
if (response.status >= 400) return null;
const text = await response.text();
return text;
} catch {
return null;
}
}
/**
* Parses a Usher response and returns a map of video quality to URL.
* @param manifest
* @returns
*/
function parseUsherManifest(manifest: string): Map<string, string> | null {
const parser = new m3u8Parser.Parser();
parser.push(manifest);
parser.end();
const parsedManifest = parser.manifest;
if (!parsedManifest.playlists || parsedManifest.playlists.length === 0) {
return null;
}
return new Map(
parsedManifest.playlists.map(playlist => [
playlist.attributes.VIDEO,
playlist.uri,
])
);
}
/**
* Updates the replacement Video Weaver URLs.
* @param pageState
* @param cachedUsherRequestUrl
* @param manifest
* @returns
*/
async function updateVideoWeaverReplacementMap(
pageState: PageState,
cachedUsherRequestUrl: string | null,
manifest: UsherManifest
): Promise<boolean> {
console.log("[TTV LOL PRO] Getting replacement Video Weaver URLs…");
try {
console.log("[TTV LOL PRO] (1/3) Getting new PlaybackAccessToken…");
const newPlaybackAccessTokenResponse =
await pageState.sendMessageToPageScriptAndWaitForResponse(
"worker",
{
type: MessageType.NewPlaybackAccessToken,
},
MessageType.NewPlaybackAccessTokenResponse
);
const newPlaybackAccessToken: PlaybackAccessToken | undefined =
newPlaybackAccessTokenResponse?.newPlaybackAccessToken;
if (newPlaybackAccessToken == null) {
console.error("[TTV LOL PRO] Failed to get new PlaybackAccessToken.");
return false;
}
console.log("[TTV LOL PRO] (2/3) Fetching new Usher manifest…");
const newUsherManifest = await fetchReplacementUsherManifest(
pageState,
cachedUsherRequestUrl,
newPlaybackAccessToken
);
if (newUsherManifest == null) {
console.error("[TTV LOL PRO] Failed to fetch new Usher manifest.");
return false;
}
console.log("[TTV LOL PRO] (3/3) Parsing new Usher manifest…");
const replacementMap = parseUsherManifest(newUsherManifest);
if (replacementMap == null || replacementMap.size === 0) {
console.error("[TTV LOL PRO] Failed to parse new Usher manifest.");
return false;
}
console.log(
"[TTV LOL PRO] Replacement Video Weaver URLs:",
Object.fromEntries(replacementMap)
);
manifest.replacementMap = replacementMap;
// Send replacement Video Weaver URLs to content script.
const videoWeaverUrls = [...replacementMap.values()];
if (cachedUsherRequestUrl != null && videoWeaverUrls.length > 0) {
pageState.sendMessageToContentScript({
type: MessageType.UsherResponse,
channel: findChannelFromUsherUrl(cachedUsherRequestUrl),
videoWeaverUrls,
proxyCountry:
/USER-COUNTRY="([A-Z]+)"/i.exec(newUsherManifest)?.[1] || undefined,
});
}
return true;
} catch (error) {
console.error(
"[TTV LOL PRO] Failed to get replacement Video Weaver URLs:",
error
);
return false;
}
}
//#endregion

View File

@ -1,31 +1,65 @@
import { FetchOptions, getFetch } from "./getFetch";
import findChannelFromTwitchTvUrl from "../common/ts/findChannelFromTwitchTvUrl";
import toAbsoluteUrl from "../common/ts/toAbsoluteUrl";
import { MessageType } from "../types";
import { getFetch } from "./getFetch";
import {
getSendMessageToContentScript,
getSendMessageToContentScriptAndWaitForResponse,
getSendMessageToPageScript,
getSendMessageToPageScriptAndWaitForResponse,
getSendMessageToWorkerScript,
getSendMessageToWorkerScriptAndWaitForResponse,
} from "./sendMessage";
import type { PageState } from "./types";
console.info("[TTV LOL PRO] 🚀 Page script running.");
console.info("[TTV LOL PRO] Page script running.");
const params = JSON.parse(document.currentScript!.dataset.params!);
const options: FetchOptions = {
const sendMessageToContentScript = getSendMessageToContentScript();
const sendMessageToContentScriptAndWaitForResponse =
getSendMessageToContentScriptAndWaitForResponse();
const sendMessageToPageScript = getSendMessageToPageScript();
const sendMessageToPageScriptAndWaitForResponse =
getSendMessageToPageScriptAndWaitForResponse();
const sendMessageToWorkerScript = getSendMessageToWorkerScript();
const sendMessageToWorkerScriptAndWaitForResponse =
getSendMessageToWorkerScriptAndWaitForResponse();
const pageState: PageState = {
isChromium: params.isChromium,
scope: "page",
shouldWaitForStore: params.isChromium === false,
state: undefined,
twitchWorker: undefined,
sendMessageToContentScript,
sendMessageToContentScriptAndWaitForResponse,
sendMessageToPageScript,
sendMessageToPageScriptAndWaitForResponse,
sendMessageToWorkerScript,
sendMessageToWorkerScriptAndWaitForResponse,
};
window.fetch = getFetch(options);
window.fetch = getFetch(pageState);
window.Worker = class Worker extends window.Worker {
constructor(scriptURL: string | URL, options?: WorkerOptions) {
const url = scriptURL.toString();
const fullUrl = toAbsoluteUrl(scriptURL.toString());
const isTwitchWorker = fullUrl.includes(".twitch.tv");
if (!isTwitchWorker) {
super(scriptURL, options);
return;
}
let script = "";
// Fetch Twitch's script, since Firefox Nightly errors out when trying to
// import a blob URL directly.
const xhr = new XMLHttpRequest();
xhr.open("GET", url, false);
xhr.open("GET", fullUrl, false);
xhr.send();
if (200 <= xhr.status && xhr.status < 300) {
script = xhr.responseText;
} else {
console.warn(
`[TTV LOL PRO] ❌ Failed to fetch script: ${xhr.statusText}`
);
script = `importScripts("${url}");`; // Will fail on Firefox Nightly.
console.warn(`[TTV LOL PRO] Failed to fetch script: ${xhr.statusText}`);
script = `importScripts("${fullUrl}");`; // Will fail on Firefox Nightly.
}
// ---------------------------------------
// 🦊 Attention Firefox Addon Reviewer 🦊
@ -33,10 +67,13 @@ window.Worker = class Worker extends window.Worker {
// Please note that this does NOT involve remote code execution. The injected script is bundled
// with the extension. Additionally, there is no custom Content Security Policy (CSP) in use.
const newScript = `
var getParams = () => '${JSON.stringify(params)}';
try {
importScripts("${params.workerScriptURL}");
} catch {
console.error("[TTV LOL PRO] ❌ Failed to load worker script: ${params.workerScriptURL}");
} catch (error) {
console.error("[TTV LOL PRO] Failed to load worker script: ${
params.workerScriptURL
}:", error);
}
${script}
`;
@ -46,27 +83,113 @@ window.Worker = class Worker extends window.Worker {
super(newScriptURL, options);
this.addEventListener("message", event => {
if (
event.data?.type === "ContentScriptMessage" ||
event.data?.type === "PageScriptMessage"
event.data?.type === MessageType.ContentScriptMessage ||
event.data?.type === MessageType.PageScriptMessage
) {
window.postMessage(event.data.message);
window.postMessage(event.data);
}
});
pageState.twitchWorker = this;
}
};
let sendStoreStateToWorker = false;
window.addEventListener("message", event => {
if (event.data?.type === "PageScriptMessage") {
const message = event.data.message;
if (message.type === "StoreReady") {
console.log(
"[TTV LOL PRO] 📦 Page received store state from content script."
);
// Mutate the options object.
options.state = message.state;
options.shouldWaitForStore = false;
}
// Relay messages from the content script to the worker script.
if (event.data?.type === MessageType.WorkerScriptMessage) {
sendMessageToWorkerScript(pageState.twitchWorker, event.data.message);
return;
}
if (event.data?.type !== MessageType.PageScriptMessage) return;
const message = event.data?.message;
if (!message) return;
switch (message.type) {
case MessageType.GetStoreState: // From Worker
if (pageState.state != null) {
sendMessageToWorkerScript(pageState.twitchWorker, {
type: MessageType.GetStoreStateResponse,
state: pageState.state,
});
}
sendStoreStateToWorker = true;
break;
case MessageType.GetStoreStateResponse: // From Content
if (pageState.state == null) {
console.log("[TTV LOL PRO] Received store state from content script.");
} else {
console.debug(
"[TTV LOL PRO] Received store state from content script."
);
}
const state = message.state;
pageState.state = state;
if (sendStoreStateToWorker) {
sendMessageToWorkerScript(pageState.twitchWorker, {
type: MessageType.GetStoreStateResponse,
state,
});
}
break;
}
});
function onChannelChange(callback: (channelName: string) => void) {
let channelName: string | null = findChannelFromTwitchTvUrl(location.href);
const NATIVE_PUSH_STATE = window.history.pushState;
function pushState(
data: any,
unused: string,
url?: string | URL | null | undefined
) {
if (!url) return NATIVE_PUSH_STATE.call(window.history, data, unused);
const fullUrl = toAbsoluteUrl(url.toString());
const newChannelName = findChannelFromTwitchTvUrl(fullUrl);
if (newChannelName != null && newChannelName !== channelName) {
channelName = newChannelName;
callback(channelName);
}
return NATIVE_PUSH_STATE.call(window.history, data, unused, url);
}
window.history.pushState = pushState;
const NATIVE_REPLACE_STATE = window.history.replaceState;
function replaceState(
data: any,
unused: string,
url?: string | URL | null | undefined
) {
if (!url) return NATIVE_REPLACE_STATE.call(window.history, data, unused);
const fullUrl = toAbsoluteUrl(url.toString());
const newChannelName = findChannelFromTwitchTvUrl(fullUrl);
if (newChannelName != null && newChannelName !== channelName) {
channelName = newChannelName;
callback(channelName);
}
return NATIVE_REPLACE_STATE.call(window.history, data, unused, url);
}
window.history.replaceState = replaceState;
window.addEventListener("popstate", () => {
const newChannelName = findChannelFromTwitchTvUrl(location.href);
if (newChannelName != null && newChannelName !== channelName) {
channelName = newChannelName;
callback(channelName);
}
});
}
onChannelChange(() => {
sendMessageToContentScript({ type: MessageType.ClearStats });
sendMessageToPageScript({ type: MessageType.ClearStats });
sendMessageToWorkerScript(pageState.twitchWorker, {
type: MessageType.ClearStats,
});
});
sendMessageToContentScript({ type: MessageType.GetStoreState });
document.currentScript!.remove();

130
src/page/sendMessage.ts Normal file
View File

@ -0,0 +1,130 @@
import { MessageType } from "../types";
import type {
SendMessageAndWaitForResponseFn,
SendMessageAndWaitForResponseWorkerFn,
SendMessageFn,
SendMessageWorkerFn,
} from "./types";
// TODO: Secure communication between content, page, and worker scripts.
function sendMessage(
recipient: Window | Worker | undefined,
type: MessageType,
message: any
): void {
if (!recipient) return;
recipient.postMessage({
type,
message,
});
}
async function sendMessageAndWaitForResponse(
recipient: Window | Worker | undefined,
type: MessageType,
message: any,
responseType: MessageType,
responseMessageType: MessageType,
responseTimeout: number
): Promise<any> {
return new Promise((resolve, reject) => {
if (!recipient) {
console.warn("[TTV LOL PRO] Recipient is undefined.");
resolve(undefined);
return;
}
const listener = (event: MessageEvent) => {
if (event.data?.type !== responseType) return;
const message = event.data?.message;
if (!message) return;
if (message.type === responseMessageType) {
resolve(message);
}
};
self.addEventListener("message", listener);
sendMessage(recipient, type, message);
setTimeout(() => {
self.removeEventListener("message", listener);
reject(new Error("Timed out waiting for message response."));
}, responseTimeout);
});
}
export function getSendMessageToContentScript(): SendMessageFn {
return (message: any) =>
sendMessage(self, MessageType.ContentScriptMessage, message);
}
export function getSendMessageToContentScriptAndWaitForResponse(): SendMessageAndWaitForResponseFn {
return async (
scope: "page" | "worker",
message: any,
responseMessageType: MessageType,
responseTimeout: number = 5000
) => {
return sendMessageAndWaitForResponse(
self,
MessageType.ContentScriptMessage,
message,
scope === "page"
? MessageType.PageScriptMessage
: MessageType.WorkerScriptMessage,
responseMessageType,
responseTimeout
);
};
}
export function getSendMessageToPageScript(): SendMessageFn {
return (message: any) =>
sendMessage(self, MessageType.PageScriptMessage, message);
}
export function getSendMessageToPageScriptAndWaitForResponse(): SendMessageAndWaitForResponseFn {
return async (
scope: "page" | "worker",
message: any,
responseMessageType: MessageType,
responseTimeout: number = 5000
) => {
return sendMessageAndWaitForResponse(
self,
MessageType.PageScriptMessage,
message,
scope === "page"
? MessageType.PageScriptMessage
: MessageType.WorkerScriptMessage,
responseMessageType,
responseTimeout
);
};
}
export function getSendMessageToWorkerScript(): SendMessageWorkerFn {
return (worker: Worker | undefined, message: any) =>
sendMessage(worker, MessageType.WorkerScriptMessage, message);
}
export function getSendMessageToWorkerScriptAndWaitForResponse(): SendMessageAndWaitForResponseWorkerFn {
return async (
worker: Worker | undefined,
message: any,
responseMessageType: MessageType,
scope: "page" | "worker",
responseTimeout: number = 5000
) => {
return sendMessageAndWaitForResponse(
worker,
MessageType.WorkerScriptMessage,
message,
scope === "page"
? MessageType.PageScriptMessage
: MessageType.WorkerScriptMessage,
responseMessageType,
responseTimeout
);
};
}

51
src/page/types.ts Normal file
View File

@ -0,0 +1,51 @@
import type { State } from "../store/types";
import { MessageType } from "../types";
export type SendMessageFn = (message: any) => void;
export type SendMessageWorkerFn = (
worker: Worker | undefined,
message: any
) => void;
export type SendMessageAndWaitForResponseFn = (
scope: "page" | "worker",
message: any,
responseMessageType: MessageType,
responseTimeout?: number
) => Promise<any>;
export type SendMessageAndWaitForResponseWorkerFn = (
worker: Worker | undefined,
message: any,
responseMessageType: MessageType,
scope: "page" | "worker",
responseTimeout?: number
) => Promise<any>;
export interface PageState {
isChromium: boolean;
scope: "page" | "worker";
state?: State;
twitchWorker?: Worker;
sendMessageToContentScript: SendMessageFn;
sendMessageToContentScriptAndWaitForResponse: SendMessageAndWaitForResponseFn;
sendMessageToPageScript: SendMessageFn;
sendMessageToPageScriptAndWaitForResponse: SendMessageAndWaitForResponseFn;
sendMessageToWorkerScript: SendMessageWorkerFn;
sendMessageToWorkerScriptAndWaitForResponse: SendMessageAndWaitForResponseWorkerFn;
}
export interface UsherManifest {
channelName: string | null;
assignedMap: Map<string, string>; // E.g. "720p60" -> "https://video-weaver.fra02.hls.ttvnw.net/v1/playlist/..."
replacementMap: Map<string, string> | null; // Same as above, but with new URLs.
consecutiveMidrollResponses: number; // Used to avoid infinite loops.
}
export interface PlaybackAccessToken {
value: string;
signature: string;
authorization: {
isForbidden: boolean;
forbiddenReasonCode: string;
};
__typename: string;
}

View File

@ -1,10 +1,68 @@
import { FetchOptions, getFetch } from "./getFetch";
import { MessageType } from "../types";
import { getFetch } from "./getFetch";
import {
getSendMessageToContentScript,
getSendMessageToContentScriptAndWaitForResponse,
getSendMessageToPageScript,
getSendMessageToPageScriptAndWaitForResponse,
getSendMessageToWorkerScript,
getSendMessageToWorkerScriptAndWaitForResponse,
} from "./sendMessage";
import type { PageState } from "./types";
console.info("[TTV LOL PRO] 🚀 Worker script running.");
console.info("[TTV LOL PRO] Worker script running.");
const options: FetchOptions = {
declare var getParams: () => string;
let params;
try {
params = JSON.parse(getParams()!);
} catch (error) {
console.error("[TTV LOL PRO] Failed to parse params:", error);
}
getParams = undefined as any;
const sendMessageToContentScript = getSendMessageToContentScript();
const sendMessageToContentScriptAndWaitForResponse =
getSendMessageToContentScriptAndWaitForResponse();
const sendMessageToPageScript = getSendMessageToPageScript();
const sendMessageToPageScriptAndWaitForResponse =
getSendMessageToPageScriptAndWaitForResponse();
const sendMessageToWorkerScript = getSendMessageToWorkerScript();
const sendMessageToWorkerScriptAndWaitForResponse =
getSendMessageToWorkerScriptAndWaitForResponse();
const pageState: PageState = {
isChromium: params.isChromium,
scope: "worker",
shouldWaitForStore: false,
state: undefined,
twitchWorker: undefined, // Can't get the worker instance from inside the worker.
sendMessageToContentScript,
sendMessageToContentScriptAndWaitForResponse,
sendMessageToPageScript,
sendMessageToPageScriptAndWaitForResponse,
sendMessageToWorkerScript,
sendMessageToWorkerScriptAndWaitForResponse,
};
self.fetch = getFetch(options);
self.fetch = getFetch(pageState);
self.addEventListener("message", event => {
if (event.data?.type !== MessageType.WorkerScriptMessage) return;
const message = event.data?.message;
if (!message) return;
switch (message.type) {
case MessageType.GetStoreStateResponse: // From Page
if (pageState.state == null) {
console.log("[TTV LOL PRO] Received store state from page script.");
} else {
console.debug("[TTV LOL PRO] Received store state from page script.");
}
const state = message.state;
pageState.state = state;
break;
}
});
sendMessageToPageScript({ type: MessageType.GetStoreState });

View File

@ -17,23 +17,11 @@
>options page</a
>.
</div>
<div id="warning-banner-limited-proxy" class="warning-banner">
<h3 class="warning-banner-title">You are using a limited proxy!</h3>
The proxy you are using has a limited number of simultaneous connections.
This means that you may experience buffering issues. Consider donating to
get access to unlimited proxies, or
<a
href="https://github.com/younesaassila/ttv-lol-pro/discussions/151"
target="_blank"
class="warning-banner-link"
>host your own proxy</a
>.
</div>
<main>
<!-- Logo -->
<div class="logo-wrapper">
<img src="../images/brand/icon.png" alt="TTV LOL PRO" />
<img src="../common/images/brand/icon.png" alt="TTV LOL PRO" />
</div>
<!-- Stream status -->
@ -54,7 +42,7 @@
</div>
<h3 id="channel-name"></h3>
<p id="reason"></p>
<small id="info"></small>
<div id="info-container"></div>
</div>
<div id="whitelist-status" data-whitelisted="false">
<input

View File

@ -5,26 +5,23 @@ import {
anonymizeIpAddress,
anonymizeIpAddresses,
} from "../common/ts/anonymizeIpAddress";
import { alpha2 } from "../common/ts/countryCodes";
import findChannelFromTwitchTvUrl from "../common/ts/findChannelFromTwitchTvUrl";
import getProxyInfoFromUrl from "../common/ts/getProxyInfoFromUrl";
import isChromium from "../common/ts/isChromium";
import isChannelWhitelisted from "../common/ts/isChannelWhitelisted";
import store from "../store";
import type { StreamStatus } from "../types";
type WarningBannerType = "noProxies" | "limitedProxy";
type WarningBannerType = "noProxies";
//#region HTML Elements
const warningBannerNoProxiesElement = $(
"#warning-banner-no-proxies"
) as HTMLDivElement;
const warningBannerLimitedProxyElement = $(
"#warning-banner-limited-proxy"
) as HTMLDivElement;
const streamStatusElement = $("#stream-status") as HTMLDivElement;
const proxiedElement = $("#proxied") as HTMLDivElement;
const channelNameElement = $("#channel-name") as HTMLHeadingElement;
const reasonElement = $("#reason") as HTMLParagraphElement;
const infoElement = $("#info") as HTMLElement;
const infoContainerElement = $("#info-container") as HTMLDivElement;
const whitelistStatusElement = $("#whitelist-status") as HTMLDivElement;
const whitelistToggleElement = $("#whitelist-toggle") as HTMLInputElement;
const copyDebugInfoButtonElement = $(
@ -39,21 +36,11 @@ if (store.readyState === "complete") main();
else store.addEventListener("load", main);
async function main() {
let proxies: string[];
if (isChromium) {
proxies = store.state.normalProxies;
} else {
proxies = store.state.optimizedProxiesEnabled
? store.state.optimizedProxies
: store.state.normalProxies;
}
const isLimitedProxy =
proxies.length > 0 &&
getProxyInfoFromUrl(proxies[0]).host === "chrome.api.cdn-perfprod.com";
const proxies = store.state.optimizedProxiesEnabled
? store.state.optimizedProxies
: store.state.normalProxies;
if (proxies.length === 0) {
setWarningBanner("noProxies");
} else if (isLimitedProxy) {
setWarningBanner("limitedProxy");
}
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
@ -68,20 +55,22 @@ async function main() {
}
function setWarningBanner(type: WarningBannerType) {
if (type === "noProxies") {
warningBannerNoProxiesElement.style.display = "block";
warningBannerLimitedProxyElement.style.display = "none";
} else if (type === "limitedProxy") {
warningBannerNoProxiesElement.style.display = "none";
warningBannerLimitedProxyElement.style.display = "block";
// Hide all warning banners.
warningBannerNoProxiesElement.style.display = "none";
switch (type) {
case "noProxies":
warningBannerNoProxiesElement.style.display = "block";
break;
}
}
function setStreamStatusElement(channelName: string) {
const channelNameLower = channelName.toLowerCase();
const isWhitelisted = isChannelWhitelisted(channelNameLower);
const status = store.state.streamStatuses[channelNameLower];
if (status) {
setProxyStatus(channelNameLower, status);
setProxyStatus(channelNameLower, isWhitelisted, status);
setWhitelistStatus(channelNameLower);
streamStatusElement.style.display = "flex";
} else {
@ -89,25 +78,35 @@ function setStreamStatusElement(channelName: string) {
}
}
function setProxyStatus(channelNameLower: string, status: StreamStatus) {
function setProxyStatus(
channelNameLower: string,
isWhitelisted: boolean,
status: StreamStatus
) {
// Proxied
if (status.proxied) {
proxiedElement.classList.remove("error");
proxiedElement.classList.remove("idle");
proxiedElement.classList.add("success");
proxiedElement.title = "Proxying";
} else if (
!status.proxied &&
status.proxyHost &&
status.stats &&
status.stats.proxied > 0 &&
store.state.optimizedProxiesEnabled &&
store.state.optimizedProxies.length > 0
store.state.optimizedProxies.length > 0 &&
!isWhitelisted
) {
proxiedElement.classList.remove("error");
proxiedElement.classList.remove("success");
proxiedElement.classList.add("idle");
proxiedElement.title = "Idling";
} else {
proxiedElement.classList.remove("success");
proxiedElement.classList.remove("idle");
proxiedElement.classList.add("error");
proxiedElement.title = "Not proxying";
}
// Channel name
channelNameElement.textContent = channelNameLower;
@ -124,14 +123,24 @@ function setProxyStatus(channelNameLower: string, status: StreamStatus) {
messages.push(`Proxy: ${anonymizeIpAddress(status.proxyHost)}`);
}
if (status.proxyCountry) {
messages.push(`Country: ${status.proxyCountry}`);
messages.push(
`Country: ${
(alpha2 as Record<string, string>)[status.proxyCountry] ??
status.proxyCountry
}`
);
}
if (store.state.optimizedProxiesEnabled) {
messages.push("Optimized proxies enabled");
messages.push("Using optimized proxies");
}
if (messages.length > 0) {
infoElement.textContent = messages.join(", ");
infoElement.style.display = "block";
infoContainerElement.innerHTML = "";
infoContainerElement.style.display = "none";
for (const message of messages) {
const smallElement = document.createElement("small");
smallElement.className = "info";
smallElement.textContent = message;
infoContainerElement.appendChild(smallElement);
infoContainerElement.style.display = "flex";
}
}
@ -162,48 +171,70 @@ function setWhitelistStatus(channelNameLower: string) {
copyDebugInfoButtonElement.addEventListener("click", async e => {
const extensionInfo = await browser.management.getSelf();
const userAgentParser = Bowser.getParser(window.navigator.userAgent);
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
const activeTab = tabs[0];
const channelName =
activeTab?.url != null ? findChannelFromTwitchTvUrl(activeTab.url) : null;
const channelNameLower =
channelName != null ? channelName.toLowerCase() : null;
const isWhitelisted =
channelNameLower != null ? isChannelWhitelisted(channelNameLower) : null;
const status =
channelNameLower != null
? store.state.streamStatuses[channelNameLower]
: null;
const debugInfo = [
`${extensionInfo.name} v${extensionInfo.version}`,
`- Install type: ${extensionInfo.installType}`,
`- Browser: ${userAgentParser.getBrowserName()} ${userAgentParser.getBrowserVersion()}`,
`- OS: ${userAgentParser.getOSName()} ${userAgentParser.getOSVersion()}`,
`- Passport enabled: ${store.state.proxyUsherRequests}`,
`- Is laissez-passer: ${store.state.proxyTwitchWebpage}`,
`- Is redacted: ${store.state.anonymousMode}`,
`- Optimized proxies enabled: ${store.state.optimizedProxiesEnabled}`,
`- Optimized proxies: ${JSON.stringify(
e.shiftKey
? store.state.optimizedProxies
: anonymizeIpAddresses(store.state.optimizedProxies)
)}`,
`- Normal proxies: ${JSON.stringify(
e.shiftKey
? store.state.normalProxies
: anonymizeIpAddresses(store.state.normalProxies)
)}`,
isChromium
? `- Should extension be active: ${store.state.chromiumProxyActive}`
`**Debug Info**\n`,
`Extension: ${extensionInfo.name} v${extensionInfo.version} (${extensionInfo.installType})\n`,
`Browser: ${userAgentParser.getBrowserName()} ${userAgentParser.getBrowserVersion()} (${userAgentParser.getOSName()} ${userAgentParser.getOSVersion()})\n`,
`Options:\n`,
`- Passport level: ${store.state.passportLevel}\n`,
`- Anonymous mode: ${store.state.anonymousMode}\n`,
store.state.optimizedProxiesEnabled
? `- Using optimized proxies: ${JSON.stringify(
e.shiftKey
? store.state.optimizedProxies
: anonymizeIpAddresses(store.state.optimizedProxies)
)}\n`
: `- Using normal proxies: ${JSON.stringify(
e.shiftKey
? store.state.normalProxies
: anonymizeIpAddresses(store.state.normalProxies)
)}\n`,
channelName != null
? [
`Channel name: ${channelName}${
isWhitelisted ? " (whitelisted)" : ""
}\n`,
`Stream status:\n`,
status != null
? [
`- Proxied: ${status.stats?.proxied ?? "N/A"}, Not proxied: ${
status.stats?.notProxied ?? "N/A"
}\n`,
`- Proxy: ${
status.proxyHost != null
? anonymizeIpAddress(status.proxyHost)
: "N/A"
}\n`,
`- Country: ${status.proxyCountry ?? "N/A"}\n`,
].join("")
: "",
].join("")
: "",
isChromium
? `- Number of opened Twitch tabs: ${store.state.openedTwitchTabs.length}`
store.state.adLog.length > 0
? `Latest ad log entry: ${JSON.stringify({
...store.state.adLog[store.state.adLog.length - 1],
videoWeaverUrl: undefined,
})}\n`
: "",
`- Last ad log entry: ${
store.state.adLog.length
? JSON.stringify({
...store.state.adLog[store.state.adLog.length - 1],
videoWeaverUrl: undefined,
})
: "N/A"
}`,
].join("\n");
].join("");
try {
await navigator.clipboard.writeText(debugInfo);
copyDebugInfoButtonDescriptionElement.textContent = "Copied to clipboard!";
} catch (error) {
console.error(error);
copyDebugInfoButtonDescriptionElement.textContent =
"Failed to copy to clipboard.";
copyDebugInfoButtonDescriptionElement.textContent = `Failed to copy to clipboard: ${error}`;
}
});

View File

@ -1,5 +1,5 @@
@font-face {
src: url("../fonts/Inter-VariableFont_slnt\,wght.ttf");
src: url("../common/fonts/Inter-VariableFont_slnt\,wght.ttf");
font-family: "Inter";
}
@ -112,6 +112,7 @@ main > * {
align-items: center;
justify-content: center;
margin-right: 10px;
cursor: help;
}
#stream-status #proxied.success {
color: var(--success-color);
@ -131,16 +132,23 @@ main > * {
/* Proxy status reason */
#stream-status #reason {
grid-area: middle-right;
margin: 2px 0 0 0;
margin: 4px 0 0 0;
font-size: 9pt;
opacity: 0.8;
}
/* Proxy status info */
#stream-status #info {
#stream-status #info-container {
display: none;
grid-area: bottom-right;
margin: 4px 0 0 0;
flex-direction: column;
align-items: flex-start;
justify-content: center;
margin: 6px 0 0 0;
gap: 2px;
}
#stream-status .info {
font-size: 7pt;
text-overflow: ellipsis;
opacity: 0.7;
}
/* Whitelist status */

View File

@ -20,15 +20,5 @@
"urlFilter": "*.twitch.tv/r/c/*",
"resourceTypes": ["image"]
}
},
{
"id": 3,
"priority": 1,
"action": {
"type": "block"
},
"condition": {
"urlFilter": "*.ads.twitch.tv/*"
}
}
]

View File

@ -6,15 +6,16 @@ export default function getDefaultState() {
adLog: [],
adLogEnabled: true,
adLogLastSent: 0,
anonymousMode: false,
anonymousMode: true,
chromiumProxyActive: false,
dnsResponses: [],
normalProxies: ["chrome.api.cdn-perfprod.com:4023"],
normalProxies: [],
openedTwitchTabs: [],
optimizedProxies: isChromium ? [] : ["firefox.api.cdn-perfprod.com:2023"],
optimizedProxiesEnabled: !isChromium,
proxyTwitchWebpage: false,
proxyUsherRequests: true,
optimizedProxies: isChromium
? ["chromium.api.cdn-perfprod.com:2023"]
: ["firefox.api.cdn-perfprod.com:2023"],
optimizedProxiesEnabled: true,
passportLevel: 0,
streamStatuses: {},
videoWeaverUrlsByChannel: {},
whitelistedChannels: [],

View File

@ -31,7 +31,7 @@ class Store<T extends Record<string | symbol, any>> {
if (newValue === undefined) continue; // Ignore deletions.
this._state[key as keyof T] = newValue;
}
this.dispatchEvent("change");
this.dispatchEvent("change", changes);
});
}
@ -68,9 +68,9 @@ class Store<T extends Record<string | symbol, any>> {
if (index !== -1) this._listenersByEvent[type].splice(index, 1);
}
dispatchEvent(type: EventType) {
dispatchEvent(type: EventType, ...args: any[]) {
const listeners = this._listenersByEvent[type] || [];
listeners.forEach(listener => listener());
listeners.forEach(listener => listener(...args));
}
}

View File

@ -16,8 +16,7 @@ export interface State {
openedTwitchTabs: Tabs.Tab[];
optimizedProxies: string[];
optimizedProxiesEnabled: boolean;
proxyTwitchWebpage: boolean;
proxyUsherRequests: boolean;
passportLevel: number;
streamStatuses: Record<string, StreamStatus>;
videoWeaverUrlsByChannel: Record<string, string[]>;
whitelistedChannels: string[];

View File

@ -23,11 +23,10 @@ export const enum AdType {
export interface AdLogEntry {
adType: AdType;
channel: string | null;
isPurpleScreen: boolean;
proxy: string | null;
proxyTwitchWebpage: boolean;
proxyUsherRequests: boolean;
channel: string | null;
passportLevel: number;
anonymousMode: boolean;
timestamp: number;
videoWeaverHost: string;
@ -51,3 +50,42 @@ export interface DnsResponse {
timestamp: number;
ttl: number;
}
export const enum MessageType {
ContentScriptMessage = "TLP_ContentScriptMessage",
PageScriptMessage = "TLP_PageScriptMessage",
WorkerScriptMessage = "TLP_WorkerScriptMessage",
GetStoreState = "TLP_GetStoreState",
GetStoreStateResponse = "TLP_GetStoreStateResponse",
EnableFullMode = "TLP_EnableFullMode",
EnableFullModeResponse = "TLP_EnableFullModeResponse",
DisableFullMode = "TLP_DisableFullMode",
UsherResponse = "TLP_UsherResponse",
NewPlaybackAccessToken = "TLP_NewPlaybackAccessToken",
NewPlaybackAccessTokenResponse = "TLP_NewPlaybackAccessTokenResponse",
ClearStats = "TLP_ClearStats",
}
export const enum ProxyRequestType {
Passport = "passport",
Usher = "usher",
VideoWeaver = "videoWeaver",
GraphQL = "graphQL",
GraphQLToken = "graphQLToken",
GraphQLIntegrity = "graphQLIntegrity",
TwitchWebpage = "twitchWebpage",
}
export type ProxyRequestParams =
| {
isChromium: true;
optimizedProxiesEnabled: boolean;
passportLevel: number;
fullModeEnabled?: boolean;
}
| {
isChromium: false;
optimizedProxiesEnabled: boolean;
passportLevel: number;
isFlagged?: boolean;
};