🔖 Release version 2.1.0

This commit is contained in:
Younes Aassila 2023-06-19 17:29:32 +02:00 committed by GitHub
commit dcd03ce4d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 452 additions and 184 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "ttv-lol-pro",
"version": "2.0.2",
"version": "2.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ttv-lol-pro",
"version": "2.0.2",
"version": "2.1.0",
"license": "GPL-3.0",
"dependencies": {
"bowser": "^2.11.0",

View File

@ -1,6 +1,6 @@
{
"name": "ttv-lol-pro",
"version": "2.0.2",
"version": "2.1.0",
"description": "TTV LOL PRO removes most livestream ads from Twitch.",
"@parcel/bundler-default": {
"minBundles": 10000000,

View File

@ -1,14 +1,15 @@
import browser from "webextension-polyfill";
import isChromium from "../common/ts/isChromium";
import updateProxySettings from "../common/ts/updateProxySettings";
import store from "../store";
import checkForOpenedTwitchTabs from "./handlers/checkForOpenedTwitchTabs";
import onAuthRequired from "./handlers/onAuthRequired";
import onBeforeSendHeaders from "./handlers/onBeforeSendHeaders";
import onBeforeUsherRequest from "./handlers/onBeforeUsherRequest";
import onBeforeVideoWeaverRequest from "./handlers/onBeforeVideoWeaverRequest";
import onHeadersReceived from "./handlers/onHeadersReceived";
import onProxyRequest from "./handlers/onProxyRequest";
import onResponseStarted from "./handlers/onResponseStarted";
import onStartupStoreCleanup from "./handlers/onStartupStoreCleanup";
import onTabCreated from "./handlers/onTabCreated";
import onTabRemoved from "./handlers/onTabRemoved";
import onTabUpdated from "./handlers/onTabUpdated";
console.info("🚀 Background script loaded.");
@ -22,13 +23,19 @@ browser.webRequest.onAuthRequired.addListener(
["blocking"]
);
// Monitor proxied status of requests.
browser.webRequest.onResponseStarted.addListener(onResponseStarted, {
urls: ["https://*.ttvnw.net/*", "https://*.twitch.tv/*"],
});
if (isChromium) {
const setProxySettings = () => {
if (store.readyState !== "complete")
return store.addEventListener("load", setProxySettings);
updateProxySettings();
};
setProxySettings();
// Check if there are any opened Twitch tabs on startup.
checkForOpenedTwitchTabs();
// Keep track of opened Twitch tabs to enable/disable the PAC script.
browser.tabs.onCreated.addListener(onTabCreated);
browser.tabs.onUpdated.addListener(onTabUpdated);
browser.tabs.onRemoved.addListener(onTabRemoved);
} else {
// Block tracking pixels.
browser.webRequest.onBeforeRequest.addListener(
@ -37,15 +44,6 @@ if (isChromium) {
["blocking"]
);
// Map channel names to Video Weaver URLs.
browser.webRequest.onBeforeRequest.addListener(
onBeforeUsherRequest,
{
urls: ["https://usher.ttvnw.net/api/channel/hls/*"],
},
["blocking"]
);
// Proxy requests.
browser.proxy.onRequest.addListener(
onProxyRequest,
@ -72,9 +70,4 @@ if (isChromium) {
},
["blocking"]
);
// Monitor responses of proxied requests.
browser.webRequest.onHeadersReceived.addListener(onHeadersReceived, {
urls: ["https://*.ttvnw.net/*", "https://*.twitch.tv/*"],
});
}

View File

@ -0,0 +1,30 @@
import browser from "webextension-polyfill";
import isChromium from "../../common/ts/isChromium";
import {
clearProxySettings,
updateProxySettings,
} from "../../common/ts/proxySettings";
import store from "../../store";
export default function checkForOpenedTwitchTabs() {
if (store.readyState !== "complete")
return store.addEventListener("load", checkForOpenedTwitchTabs);
browser.tabs
.query({ url: ["https://www.twitch.tv/*", "https://m.twitch.tv/*"] })
.then(tabs => {
if (tabs.length === 0) {
if (isChromium) clearProxySettings();
return;
}
console.log(
`🔍 Found ${tabs.length} opened Twitch tabs: ${tabs
.map(tab => tab.id)
.join(", ")}`
);
if (isChromium) {
updateProxySettings();
}
store.state.openedTwitchTabs = tabs.map(tab => tab.id);
});
}

View File

@ -1,61 +0,0 @@
import { WebRequest } from "webextension-polyfill";
import filterResponseDataWrapper from "../../common/ts/filterResponseDataWrapper";
import {
twitchApiChannelNameRegex,
videoWeaverUrlRegex,
} from "../../common/ts/regexes";
import store from "../../store";
import type { StreamStatus } from "../../types";
export default function onBeforeUsherRequest(
details: WebRequest.OnBeforeRequestDetailsType
): void | WebRequest.BlockingResponseOrPromise {
const match = twitchApiChannelNameRegex.exec(details.url);
if (!match) return;
const channelName = match[1]?.toLowerCase();
if (!channelName) return;
filterResponseDataWrapper(details, text => {
const videoWeaverUrls = text.match(videoWeaverUrlRegex);
if (!videoWeaverUrls) return text;
console.log(
`📺 Found ${videoWeaverUrls.length} video-weaver URLs for ${channelName}.`
);
const existingVideoWeaverUrls =
store.state.videoWeaverUrlsByChannel[channelName] ?? [];
const newVideoWeaverUrls = videoWeaverUrls.filter(
url => !existingVideoWeaverUrls.includes(url)
);
store.state.videoWeaverUrlsByChannel[channelName] = [
...existingVideoWeaverUrls,
...newVideoWeaverUrls,
];
const streamStatus = getStreamStatus(channelName);
setStreamStatus(channelName, {
...(streamStatus ?? { proxied: false, reason: "" }),
proxyCountry: extractProxyCountryFromManifest(text),
});
return text;
});
}
function getStreamStatus(channelName: string | null): StreamStatus | null {
if (!channelName) return null;
return store.state.streamStatuses[channelName] ?? null;
}
function setStreamStatus(
channelName: string | null,
streamStatus: StreamStatus
): boolean {
if (!channelName) return false;
store.state.streamStatuses[channelName] = streamStatus;
return true;
}
function extractProxyCountryFromManifest(text: string): string | undefined {
const match = /USER-COUNTRY="([A-Z]+)"/i.exec(text);
if (!match) return;
const [, proxyCountry] = match;
return proxyCountry;
}

View File

@ -108,7 +108,7 @@ export default async function onProxyRequest(
function getProxyInfoArrayFromUrls(urls: string[]): ProxyInfo[] {
return [
...urls.map(url => getProxyInfoFromUrl(url)),
...urls.map(getProxyInfoFromUrl),
{ type: "direct" } as ProxyInfo, // Fallback to direct connection if all proxies fail.
];
}

View File

@ -1,6 +1,8 @@
import { WebRequest } from "webextension-polyfill";
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 {
passportHostRegex,
twitchGqlHostRegex,
@ -8,14 +10,15 @@ import {
usherHostRegex,
videoWeaverHostRegex,
} from "../../common/ts/regexes";
import { getStreamStatus, setStreamStatus } from "../../common/ts/streamStatus";
import store from "../../store";
import type { ProxyInfo, StreamStatus } from "../../types";
import type { ProxyInfo } from "../../types";
export default function onHeadersReceived(
details: WebRequest.OnHeadersReceivedDetailsType & {
export default function onResponseStarted(
details: WebRequest.OnResponseStartedDetailsType & {
proxyInfo?: ProxyInfo;
}
): void | WebRequest.BlockingResponseOrPromise {
): void {
const host = getHostFromUrl(details.url);
if (!host) return;
@ -77,25 +80,31 @@ export default function onHeadersReceived(
}
function getProxyFromDetails(
details: WebRequest.OnHeadersReceivedDetailsType & {
details: WebRequest.OnResponseStartedDetailsType & {
proxyInfo?: ProxyInfo;
}
): string | null {
const proxyInfo = details.proxyInfo; // Firefox only.
if (!proxyInfo || proxyInfo.type === "direct") return null;
return `${proxyInfo.host}:${proxyInfo.port}`;
}
function getStreamStatus(channelName: string | null): StreamStatus | null {
if (!channelName) return null;
return store.state.streamStatuses[channelName] ?? null;
}
function setStreamStatus(
channelName: string | null,
streamStatus: StreamStatus
): boolean {
if (!channelName) return false;
store.state.streamStatuses[channelName] = streamStatus;
return true;
if (isChromium) {
const ip = details.ip;
if (!ip) return null;
const dnsResponse = store.state.dnsResponses.find(
dnsResponse => dnsResponse.ips.indexOf(ip) !== -1
);
if (!dnsResponse) return null;
const proxies = [
...store.state.optimizedProxies,
...store.state.normalProxies,
];
const proxyInfoArray = proxies.map(getProxyInfoFromUrl);
const possibleProxies = proxyInfoArray.filter(
proxy => proxy.host === dnsResponse.host
);
if (possibleProxies.length === 1)
return `${possibleProxies[0].host}:${possibleProxies[0].port}`;
return dnsResponse.host;
} else {
const proxyInfo = details.proxyInfo; // Firefox only.
if (!proxyInfo || proxyInfo.type === "direct") return null;
return `${proxyInfo.host}:${proxyInfo.port}`;
}
}

View File

@ -12,6 +12,8 @@ export default function onStartupStoreCleanup(): void {
if (store.readyState !== "complete")
return store.addEventListener("load", onStartupStoreCleanup);
store.state.dnsResponses = [];
store.state.openedTwitchTabs = [];
store.state.streamStatuses = {};
store.state.videoWeaverUrlsByChannel = {};
}

View File

@ -0,0 +1,18 @@
import { Tabs } from "webextension-polyfill";
import getHostFromUrl from "../../common/ts/getHostFromUrl";
import isChromium from "../../common/ts/isChromium";
import { updateProxySettings } from "../../common/ts/proxySettings";
import { twitchTvHostRegex } from "../../common/ts/regexes";
import store from "../../store";
export default function onTabCreated(tab: Tabs.Tab): void {
if (!tab.url) return;
const host = getHostFromUrl(tab.url);
if (twitchTvHostRegex.test(host)) {
console.log(` Opened Twitch tab: ${tab.id}`);
if (isChromium && store.state.openedTwitchTabs.length === 0) {
updateProxySettings();
}
store.state.openedTwitchTabs.push(tab.id);
}
}

View File

@ -0,0 +1,14 @@
import isChromium from "../../common/ts/isChromium";
import { clearProxySettings } from "../../common/ts/proxySettings";
import store from "../../store";
export default function onTabRemoved(tabId: number): void {
const index = store.state.openedTwitchTabs.indexOf(tabId);
if (index !== -1) {
console.log(` Closed Twitch tab: ${tabId}`);
store.state.openedTwitchTabs.splice(index, 1);
if (isChromium && store.state.openedTwitchTabs.length === 0) {
clearProxySettings();
}
}
}

View File

@ -0,0 +1,42 @@
import { Tabs } from "webextension-polyfill";
import getHostFromUrl from "../../common/ts/getHostFromUrl";
import isChromium from "../../common/ts/isChromium";
import {
clearProxySettings,
updateProxySettings,
} from "../../common/ts/proxySettings";
import { twitchTvHostRegex } from "../../common/ts/regexes";
import store from "../../store";
export default function onTabUpdated(
tabId: number,
changeInfo: Tabs.OnUpdatedChangeInfoType,
tab: Tabs.Tab
): void {
// Also check for `changeInfo.status === "complete"` because the `url` property
// is not always accurate when navigating to a new page.
if (!(changeInfo.url || changeInfo.status === "complete")) return;
const url = changeInfo.url || tab.url;
const host = getHostFromUrl(url);
const isTwitchTab = twitchTvHostRegex.test(host);
const wasTwitchTab = store.state.openedTwitchTabs.includes(tabId);
if (isTwitchTab && !wasTwitchTab) {
console.log(` Opened Twitch tab: ${tabId}`);
if (isChromium && store.state.openedTwitchTabs.length === 0) {
updateProxySettings();
}
store.state.openedTwitchTabs.push(tabId);
}
if (!isTwitchTab && wasTwitchTab) {
const index = store.state.openedTwitchTabs.indexOf(tabId);
if (index !== -1) {
console.log(` Closed Twitch tab: ${tabId}`);
store.state.openedTwitchTabs.splice(index, 1);
if (isChromium && store.state.openedTwitchTabs.length === 0) {
clearProxySettings();
}
}
}
}

37
src/common/ts/file.ts Normal file
View File

@ -0,0 +1,37 @@
/**
* Read a file from the user's computer.
* @param accept
* @returns
*/
export async function readFile(accept = "text/plain;charset=utf-8") {
return new Promise<string>((resolve, reject) => {
const input = document.createElement("input");
input.type = "file";
input.accept = accept;
input.addEventListener("change", async e => {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return reject("No file selected");
const data = await file.text();
return resolve(data);
});
input.click();
});
}
/**
* Save a file to the user's computer.
* @param filename
* @param content
* @param type
*/
export function saveFile(
filename: string,
content: string,
type = "text/plain;charset=utf-8"
) {
const a = document.createElement("a");
a.setAttribute("href", `data:${type},` + encodeURIComponent(content));
a.setAttribute("download", filename);
a.click();
}

View File

@ -1,5 +1,11 @@
import { twitchApiChannelNameRegex } from "./regexes";
/**
* Returns the channel name from a Twitch Usher URL.
* Returns `null` if the URL is not a valid Usher URL.
* @param usherUrl
* @returns
*/
export default function findChannelFromUsherUrl(
usherUrl: string
): string | null {

View File

@ -1,5 +1,11 @@
import store from "../../store";
/**
* Returns the channel name from a Video Weaver URL.
* Returns `null` if the URL is not a valid Video Weaver URL.
* @param videoWeaverUrl
* @returns
*/
export default function findChannelFromVideoWeaverUrl(videoWeaverUrl: string) {
const channelName = Object.keys(store.state.videoWeaverUrlsByChannel).find(
channelName =>

View File

@ -7,8 +7,9 @@ import {
usherHostRegex,
videoWeaverHostRegex,
} from "./regexes";
import updateDnsResponses from "./updateDnsResponses";
export default function updateProxySettings() {
export function updateProxySettings() {
const { proxyTwitchWebpage, proxyUsherRequests } = store.state;
const proxies = store.state.optimizedProxiesEnabled
@ -43,6 +44,7 @@ export default function updateProxySettings() {
console.log(
`⚙️ Proxying requests through one of: ${proxies.toString() || "<empty>"}`
);
updateDnsResponses();
});
}
@ -55,3 +57,9 @@ function getProxyInfoStringFromUrls(urls: string[]): string {
"DIRECT",
].join("; ");
}
export function clearProxySettings() {
chrome.proxy.settings.clear({ scope: "regular" }, function () {
console.log("⚙️ Proxy settings cleared");
});
}

View File

@ -1,15 +0,0 @@
export default async function readFile(accept = "text/plain;charset=utf-8") {
return new Promise<string>((resolve, reject) => {
const input = document.createElement("input");
input.type = "file";
input.accept = accept;
input.addEventListener("change", async e => {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return reject("No file selected");
const data = await file.text();
return resolve(data);
});
input.click();
});
}

View File

@ -1,10 +0,0 @@
export default function saveFile(
filename: string,
content: string,
type = "text/plain;charset=utf-8"
) {
const a = document.createElement("a");
a.setAttribute("href", `data:${type},` + encodeURIComponent(content));
a.setAttribute("download", filename);
a.click();
}

View File

@ -0,0 +1,29 @@
import store from "../../store";
import type { StreamStatus } from "../../types";
/**
* Safely get the stream status for a channel.
* @param channelName
* @returns
*/
export function getStreamStatus(
channelName: string | null
): StreamStatus | null {
if (!channelName) return null;
return store.state.streamStatuses[channelName] ?? null;
}
/**
* Safely set the stream status for a channel.
* @param channelName
* @param streamStatus
* @returns
*/
export function setStreamStatus(
channelName: string | null,
streamStatus: StreamStatus
): boolean {
if (!channelName) return false;
store.state.streamStatuses[channelName] = streamStatus;
return true;
}

View File

@ -0,0 +1,70 @@
import ip from "ip";
import store from "../../store";
import type { DnsResponse } from "../../types";
import getProxyInfoFromUrl from "./getProxyInfoFromUrl";
export default async function updateDnsResponses() {
const proxies = [
...store.state.optimizedProxies,
...store.state.normalProxies,
];
const proxyInfoArray = proxies.map(getProxyInfoFromUrl);
for (const proxyInfo of proxyInfoArray) {
const { host } = proxyInfo;
const dnsResponseIndex = store.state.dnsResponses.findIndex(
dnsResponse => dnsResponse.host === host
);
const dnsResponse =
dnsResponseIndex !== -1
? store.state.dnsResponses[dnsResponseIndex]
: null;
if (
dnsResponse != null &&
Date.now() - dnsResponse.timestamp < dnsResponse.ttl * 1000
) {
continue;
}
if (ip.isV4Format(host) || ip.isV6Format(host)) {
if (dnsResponseIndex !== -1) {
store.state.dnsResponses.splice(dnsResponseIndex, 1);
}
store.state.dnsResponses.push({
host,
ips: [host],
timestamp: Date.now(),
ttl: Infinity,
} as DnsResponse);
continue;
}
try {
const response = await fetch(`https://dns.google/resolve?name=${host}`);
const json = await response.json();
const { Answer } = json;
if (!Array.isArray(Answer)) {
console.error("Answer is not an array:", Answer);
continue;
}
const ips = Answer.map((answer: any) => answer.data);
const ttl =
Number(response.headers.get("Cache-Control").split("=")[1]) || 0;
if (dnsResponseIndex !== -1) {
store.state.dnsResponses.splice(dnsResponseIndex, 1);
}
store.state.dnsResponses.push({
host,
ips,
timestamp: Date.now(),
ttl,
} as DnsResponse);
} catch (error) {
console.error(error);
}
}
console.log("🔍 DNS responses updated:");
console.log(store.state.dnsResponses);
}

View File

@ -1,37 +1,39 @@
import pageScript from "url:../page/page.ts";
import workerScript from "url:../page/worker.ts";
import pageScriptURL from "url:../page/page.ts";
import workerScriptURL from "url:../page/worker.ts";
import { twitchChannelNameRegex } from "../common/ts/regexes";
import { getStreamStatus, setStreamStatus } from "../common/ts/streamStatus";
import store from "../store";
console.info("[TTV LOL PRO] 🚀 Content script running.");
injectScript(pageScript);
injectPageScript();
function injectScript(src: string) {
if (store.readyState === "complete") clearStats();
else store.addEventListener("load", clearStats);
window.addEventListener("message", onMessage);
function injectPageScript() {
// From https://stackoverflow.com/a/9517879
const script = document.createElement("script");
script.src = src;
script.src = pageScriptURL; // src/page/page.ts
script.dataset.params = JSON.stringify({
workerScriptURL: workerScript,
workerScriptURL: workerScriptURL, // src/page/worker.ts
});
script.onload = () => script.remove();
// ------------------------------------------
// 🦊🦊🦊 DEAR FIREFOX ADDON REVIEWER 🦊🦊🦊
// ------------------------------------------
// This is NOT remote code execution. The script being injected is
// bundled with the extension (look at the `url:` imports above provided by
// the Parcel bundler). By the way, no custom CSP is used.
// ---------------------------------------
// 🦊 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).append(script); // Note: Despite what the TS types say, `document.head` can be `null`.
}
if (store.readyState === "complete") onStoreReady();
else store.addEventListener("load", onStoreReady);
function onStoreReady() {
// Clear stats for stream on page load/reload.
clearStats();
}
/**
* Clear stats for stream on page load/reload.
* @returns
*/
function clearStats() {
const match = twitchChannelNameRegex.exec(location.href);
if (!match) return;
@ -45,3 +47,18 @@ function clearStats() {
};
}
}
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] = videoWeaverUrls;
// Update proxy country.
const streamStatus = getStreamStatus(channel);
setStreamStatus(channel, {
...(streamStatus ?? { proxied: false, reason: "" }),
proxyCountry,
});
}
}

View File

@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "TTV LOL PRO",
"description": "TTV LOL PRO removes most livestream ads from Twitch.",
"version": "2.0.2",
"version": "2.1.0",
"background": {
"service_worker": "background/background.ts",
"type": "module"
@ -10,9 +10,9 @@
"declarative_net_request": {
"rule_resources": [
{
"id": "rules",
"id": "ruleset",
"enabled": true,
"path": "rules/rules.json"
"path": "rulesets/ruleset.json"
}
]
},
@ -23,6 +23,13 @@
"default_title": "TTV LOL PRO",
"default_popup": "popup/menu.html"
},
"content_scripts": [
{
"matches": ["https://www.twitch.tv/*", "https://m.twitch.tv/*"],
"js": ["content/content.ts"],
"run_at": "document_start"
}
],
"icons": {
"128": "images/brand/icon.png"
},
@ -35,6 +42,7 @@
"declarativeNetRequest",
"proxy",
"storage",
"tabs",
"webRequest",
"webRequestAuthProvider"
],

View File

@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "TTV LOL PRO",
"description": "TTV LOL PRO removes most livestream ads from Twitch.",
"version": "2.0.2",
"version": "2.1.0",
"background": {
"scripts": ["background/background.ts"],
"persistent": false
@ -22,7 +22,7 @@
},
"content_scripts": [
{
"matches": ["https://*.twitch.tv/*"],
"matches": ["https://www.twitch.tv/*", "https://m.twitch.tv/*"],
"js": ["content/content.ts"],
"run_at": "document_start"
}

View File

@ -1,10 +1,9 @@
import $ from "../common/ts/$";
import { readFile, saveFile } from "../common/ts/file";
import getProxyInfoFromUrl from "../common/ts/getProxyInfoFromUrl";
import isChromium from "../common/ts/isChromium";
import readFile from "../common/ts/readFile";
import saveFile from "../common/ts/saveFile";
import { updateProxySettings } from "../common/ts/proxySettings";
import sendAdLog from "../common/ts/sendAdLog";
import updateProxySettings from "../common/ts/updateProxySettings";
import store from "../store";
import getDefaultState from "../store/getDefaultState";
import type { State } from "../store/types";
@ -92,12 +91,16 @@ function main() {
proxyUsherRequestsCheckboxElement.addEventListener("change", () => {
const checked = proxyUsherRequestsCheckboxElement.checked;
store.state.proxyUsherRequests = checked;
if (isChromium) updateProxySettings();
if (isChromium && store.state.openedTwitchTabs.length > 0) {
updateProxySettings();
}
});
proxyTwitchWebpageCheckboxElement.checked = store.state.proxyTwitchWebpage;
proxyTwitchWebpageCheckboxElement.addEventListener("change", () => {
store.state.proxyTwitchWebpage = proxyTwitchWebpageCheckboxElement.checked;
if (isChromium) updateProxySettings();
if (isChromium && store.state.openedTwitchTabs.length > 0) {
updateProxySettings();
}
});
// Whitelisted channels
if (isChromium) {
@ -142,7 +145,9 @@ function main() {
isAddAllowed: isNormalProxyUrlAllowed,
isEditAllowed: isNormalProxyUrlAllowed,
onEdit() {
if (isChromium) updateProxySettings();
if (isChromium && store.state.openedTwitchTabs.length > 0) {
updateProxySettings();
}
},
hidePromptMarker: true,
insertMode: "both",

View File

@ -1,16 +1,21 @@
import acceptFlag from "../common/ts/acceptFlag";
import findChannelFromUsherUrl from "../common/ts/findChannelFromUsherUrl";
import getHostFromUrl from "../common/ts/getHostFromUrl";
import {
twitchGqlHostRegex,
usherHostRegex,
videoWeaverHostRegex,
videoWeaverUrlRegex,
} from "../common/ts/regexes";
const NATIVE_FETCH = self.fetch;
const IS_CHROMIUM = !!self.chrome;
export interface FetchOptions {}
export interface FetchOptions {
scope: "page" | "worker";
}
export function getFetch(options: FetchOptions = {}): typeof fetch {
export function getFetch(options: FetchOptions): typeof fetch {
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.
@ -22,7 +27,13 @@ export function getFetch(options: FetchOptions = {}): typeof fetch {
const url = input instanceof Request ? input.url : input.toString();
// Firefox doesn't support relative URLs in content scripts (workers too!).
// See https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Chrome_incompatibilities#content_script_https_requests
if (url.startsWith("/")) {
if (url.startsWith("//")) {
// Missing protocol.
const newUrl = `${location.protocol}${url}`;
if (input instanceof Request) input = new Request(newUrl, input);
else input = newUrl;
} else if (url.startsWith("/")) {
// Missing origin.
const newUrl = `${location.origin}${url}`;
if (input instanceof Request) input = new Request(newUrl, input);
else input = newUrl;
@ -115,12 +126,19 @@ export function getFetch(options: FetchOptions = {}): typeof fetch {
if (host != null && usherHostRegex.test(host)) {
await readResponseBody();
console.debug("[TTV LOL PRO] 🥅 Caught Usher response.");
// Remove all Video Weaver URLs from known URLs.
responseBody.split("\n").forEach(line => {
if (line.includes("video-weaver.")) {
knownVideoWeaverUrls.delete(line.trim());
}
const videoWeaverUrls = responseBody
.split("\n")
.filter(line => videoWeaverUrlRegex.test(line));
// Send Video Weaver URLs to content script.
sendMessageToContentScript(options.scope, {
type: "UsherResponse",
channel: findChannelFromUsherUrl(url),
videoWeaverUrls,
proxyCountry:
/USER-COUNTRY="([A-Z]+)"/i.exec(responseBody)?.[1] || null,
});
// Remove all Video Weaver URLs from known URLs.
videoWeaverUrls.forEach(url => knownVideoWeaverUrls.delete(url));
}
// Video Weaver responses.
@ -246,7 +264,24 @@ function removeHeaderFromMap(headersMap: Map<string, string>, name: string) {
}
}
function sendMessageToContentScript(scope: "page" | "worker", message: any) {
if (scope === "page") {
self.postMessage(message);
} else {
self.postMessage({
type: "ContentScriptMessage",
message,
});
}
}
function flagRequest(headersMap: Map<string, string>) {
if (IS_CHROMIUM) {
console.debug(
"[TTV LOL PRO] 🚩 Request flagging is not supported on Chromium. Ignoring…"
);
return;
}
const accept = getHeaderFromMap(headersMap, "Accept");
setHeaderToMap(headersMap, "Accept", `${accept || ""}${acceptFlag}`);
}

View File

@ -4,10 +4,8 @@ console.info("[TTV LOL PRO] 🚀 Page script running.");
const params = JSON.parse(document.currentScript.dataset.params);
window.fetch = getFetch();
window.fetch = getFetch({ scope: "page" });
// Inject custom worker script to intercept fetch requests made from workers and
// decide whether to proxy them or not.
window.Worker = class Worker extends window.Worker {
constructor(scriptURL: string | URL, options?: WorkerOptions) {
const url = scriptURL.toString();
@ -25,6 +23,11 @@ window.Worker = class Worker extends window.Worker {
);
script = `importScripts("${url}");`; // Will fail on Firefox Nightly.
}
// ---------------------------------------
// 🦊 Attention Firefox Addon Reviewer 🦊
// ---------------------------------------
// 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 = `
try {
importScripts("${params.workerScriptURL}");
@ -37,6 +40,11 @@ window.Worker = class Worker extends window.Worker {
new Blob([newScript], { type: "text/javascript" })
);
super(newScriptURL, options);
this.addEventListener("message", event => {
if (event.data?.type === "ContentScriptMessage") {
window.postMessage(event.data.message);
}
});
}
};

View File

@ -2,4 +2,4 @@ import { getFetch } from "./getFetch";
console.info("[TTV LOL PRO] 🚀 Worker script running.");
self.fetch = getFetch();
self.fetch = getFetch({ scope: "worker" });

View File

@ -67,6 +67,9 @@ function setStreamStatusElement(channelName: string) {
setProxyStatus(channelNameLower, status);
setWhitelistStatus(channelNameLower);
streamStatusElement.style.display = "flex";
if (isChromium) {
whitelistStatusElement.style.display = "none";
}
} else {
streamStatusElement.style.display = "none";
}
@ -80,6 +83,7 @@ function setProxyStatus(channelNameLower: string, status: StreamStatus) {
proxiedElement.classList.add("success");
} else if (
!status.proxied &&
status.proxyHost &&
store.state.optimizedProxiesEnabled &&
store.state.optimizedProxies.length > 0
) {

View File

@ -2,11 +2,13 @@ import isChromium from "../common/ts/isChromium";
import type { State } from "./types";
export default function getDefaultState() {
return {
const state: State = {
adLog: [],
adLogEnabled: true,
adLogLastSent: 0,
dnsResponses: [],
normalProxies: isChromium ? ["chrome.api.cdn-perfprod.com:4023"] : [],
openedTwitchTabs: [],
optimizedProxies: isChromium ? [] : ["firefox.api.cdn-perfprod.com:2023"],
optimizedProxiesEnabled: !isChromium,
proxyTwitchWebpage: false,
@ -14,5 +16,6 @@ export default function getDefaultState() {
streamStatuses: {},
videoWeaverUrlsByChannel: {},
whitelistedChannels: [],
} as State;
};
return state;
}

View File

@ -1,4 +1,4 @@
import type { AdLogEntry, StreamStatus } from "../types";
import type { AdLogEntry, DnsResponse, StreamStatus } from "../types";
export type EventType = "load" | "change";
export type ReadyState = "loading" | "complete";
@ -8,7 +8,9 @@ export interface State {
adLog: AdLogEntry[];
adLogEnabled: boolean;
adLogLastSent: number;
dnsResponses: DnsResponse[];
normalProxies: string[];
openedTwitchTabs: number[];
optimizedProxies: string[];
optimizedProxiesEnabled: boolean;
proxyTwitchWebpage: boolean;

View File

@ -43,3 +43,10 @@ export interface StreamStatus {
notProxied: number;
};
}
export interface DnsResponse {
host: string;
ips: string[];
timestamp: number;
ttl: number;
}

View File

@ -2,6 +2,7 @@
"compilerOptions": {
"target": "ES2020",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"noEmit": true
}
}