🔖 Release version 2.3.0
28
README.md
@ -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
17
package.json
@ -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",
|
||||
|
@ -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"]
|
||||
);
|
||||
|
@ -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[] = [];
|
||||
|
44
src/background/handlers/onBeforeTwitchTvSendHeaders.ts
Normal 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
|
||||
);
|
||||
});
|
||||
}
|
@ -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);
|
||||
|
||||
|
75
src/background/handlers/onContentScriptMessage.ts
Normal 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})`
|
||||
);
|
||||
}
|
||||
}
|
41
src/background/handlers/onInstalledStoreCleanup.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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" };
|
||||
}
|
||||
|
||||
|
@ -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";
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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 = [];
|
||||
|
@ -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);
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
BIN
src/common/images/options_bg.png
Normal file
After Width: | Height: | Size: 91 KiB |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
@ -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.
|
||||
}
|
||||
|
||||
/**
|
||||
|
254
src/common/ts/countryCodes.ts
Normal 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 };
|
92
src/common/ts/isRequestTypeProxied.ts
Normal 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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
@ -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;
|
||||
|
8
src/common/ts/toAbsoluteUrl.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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 = [
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 45 KiB |
73
src/m3u8-parser.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
175
src/page/page.ts
@ -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
@ -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
@ -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;
|
||||
}
|
@ -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 });
|
||||
|
@ -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
|
||||
|
@ -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}`;
|
||||
}
|
||||
});
|
||||
|
@ -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 */
|
||||
|
@ -20,15 +20,5 @@
|
||||
"urlFilter": "*.twitch.tv/r/c/*",
|
||||
"resourceTypes": ["image"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"priority": 1,
|
||||
"action": {
|
||||
"type": "block"
|
||||
},
|
||||
"condition": {
|
||||
"urlFilter": "*.ads.twitch.tv/*"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@ -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: [],
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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[];
|
||||
|
44
src/types.ts
@ -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;
|
||||
};
|
||||
|