🔖 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", "name": "ttv-lol-pro",
"version": "2.0.2", "version": "2.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ttv-lol-pro", "name": "ttv-lol-pro",
"version": "2.0.2", "version": "2.1.0",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"bowser": "^2.11.0", "bowser": "^2.11.0",

View File

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

View File

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

View File

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

View File

@ -1,5 +1,11 @@
import store from "../../store"; 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) { export default function findChannelFromVideoWeaverUrl(videoWeaverUrl: string) {
const channelName = Object.keys(store.state.videoWeaverUrlsByChannel).find( const channelName = Object.keys(store.state.videoWeaverUrlsByChannel).find(
channelName => channelName =>

View File

@ -7,8 +7,9 @@ import {
usherHostRegex, usherHostRegex,
videoWeaverHostRegex, videoWeaverHostRegex,
} from "./regexes"; } from "./regexes";
import updateDnsResponses from "./updateDnsResponses";
export default function updateProxySettings() { export function updateProxySettings() {
const { proxyTwitchWebpage, proxyUsherRequests } = store.state; const { proxyTwitchWebpage, proxyUsherRequests } = store.state;
const proxies = store.state.optimizedProxiesEnabled const proxies = store.state.optimizedProxiesEnabled
@ -43,6 +44,7 @@ export default function updateProxySettings() {
console.log( console.log(
`⚙️ Proxying requests through one of: ${proxies.toString() || "<empty>"}` `⚙️ Proxying requests through one of: ${proxies.toString() || "<empty>"}`
); );
updateDnsResponses();
}); });
} }
@ -55,3 +57,9 @@ function getProxyInfoStringFromUrls(urls: string[]): string {
"DIRECT", "DIRECT",
].join("; "); ].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 pageScriptURL from "url:../page/page.ts";
import workerScript from "url:../page/worker.ts"; import workerScriptURL from "url:../page/worker.ts";
import { twitchChannelNameRegex } from "../common/ts/regexes"; import { twitchChannelNameRegex } from "../common/ts/regexes";
import { getStreamStatus, setStreamStatus } from "../common/ts/streamStatus";
import store from "../store"; import store from "../store";
console.info("[TTV LOL PRO] 🚀 Content script running."); 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 // From https://stackoverflow.com/a/9517879
const script = document.createElement("script"); const script = document.createElement("script");
script.src = src; script.src = pageScriptURL; // src/page/page.ts
script.dataset.params = JSON.stringify({ script.dataset.params = JSON.stringify({
workerScriptURL: workerScript, workerScriptURL: workerScriptURL, // src/page/worker.ts
}); });
script.onload = () => script.remove(); script.onload = () => script.remove();
// ------------------------------------------ // ---------------------------------------
// 🦊🦊🦊 DEAR FIREFOX ADDON REVIEWER 🦊🦊🦊 // 🦊 Attention Firefox Addon Reviewer 🦊
// ------------------------------------------ // ---------------------------------------
// This is NOT remote code execution. The script being injected is // Please note that this does NOT involve remote code execution. The injected scripts are bundled
// bundled with the extension (look at the `url:` imports above provided by // with the extension. The `url:` imports above are used to get the runtime URLs of the respective scripts.
// the Parcel bundler). By the way, no custom CSP is used. // 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).append(script); // Note: Despite what the TS types say, `document.head` can be `null`.
} }
if (store.readyState === "complete") onStoreReady(); /**
else store.addEventListener("load", onStoreReady); * Clear stats for stream on page load/reload.
* @returns
function onStoreReady() { */
// Clear stats for stream on page load/reload.
clearStats();
}
function clearStats() { function clearStats() {
const match = twitchChannelNameRegex.exec(location.href); const match = twitchChannelNameRegex.exec(location.href);
if (!match) return; 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, "manifest_version": 3,
"name": "TTV LOL PRO", "name": "TTV LOL PRO",
"description": "TTV LOL PRO removes most livestream ads from Twitch.", "description": "TTV LOL PRO removes most livestream ads from Twitch.",
"version": "2.0.2", "version": "2.1.0",
"background": { "background": {
"service_worker": "background/background.ts", "service_worker": "background/background.ts",
"type": "module" "type": "module"
@ -10,9 +10,9 @@
"declarative_net_request": { "declarative_net_request": {
"rule_resources": [ "rule_resources": [
{ {
"id": "rules", "id": "ruleset",
"enabled": true, "enabled": true,
"path": "rules/rules.json" "path": "rulesets/ruleset.json"
} }
] ]
}, },
@ -23,6 +23,13 @@
"default_title": "TTV LOL PRO", "default_title": "TTV LOL PRO",
"default_popup": "popup/menu.html" "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": { "icons": {
"128": "images/brand/icon.png" "128": "images/brand/icon.png"
}, },
@ -35,6 +42,7 @@
"declarativeNetRequest", "declarativeNetRequest",
"proxy", "proxy",
"storage", "storage",
"tabs",
"webRequest", "webRequest",
"webRequestAuthProvider" "webRequestAuthProvider"
], ],

View File

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

View File

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

View File

@ -1,16 +1,21 @@
import acceptFlag from "../common/ts/acceptFlag"; import acceptFlag from "../common/ts/acceptFlag";
import findChannelFromUsherUrl from "../common/ts/findChannelFromUsherUrl";
import getHostFromUrl from "../common/ts/getHostFromUrl"; import getHostFromUrl from "../common/ts/getHostFromUrl";
import { import {
twitchGqlHostRegex, twitchGqlHostRegex,
usherHostRegex, usherHostRegex,
videoWeaverHostRegex, videoWeaverHostRegex,
videoWeaverUrlRegex,
} from "../common/ts/regexes"; } from "../common/ts/regexes";
const NATIVE_FETCH = self.fetch; 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 knownVideoWeaverUrls = new Set<string>();
const videoWeaverUrlsToFlag = new Map<string, number>(); // Video Weaver URLs to flag -> number of times flagged. const videoWeaverUrlsToFlag = new Map<string, number>(); // Video Weaver URLs to flag -> number of times flagged.
const videoWeaverUrlsToIgnore = new Set<string>(); // No response check. 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(); const url = input instanceof Request ? input.url : input.toString();
// Firefox doesn't support relative URLs in content scripts (workers too!). // 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 // 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}`; const newUrl = `${location.origin}${url}`;
if (input instanceof Request) input = new Request(newUrl, input); if (input instanceof Request) input = new Request(newUrl, input);
else input = newUrl; else input = newUrl;
@ -115,12 +126,19 @@ export function getFetch(options: FetchOptions = {}): typeof fetch {
if (host != null && usherHostRegex.test(host)) { if (host != null && usherHostRegex.test(host)) {
await readResponseBody(); await readResponseBody();
console.debug("[TTV LOL PRO] 🥅 Caught Usher response."); console.debug("[TTV LOL PRO] 🥅 Caught Usher response.");
// Remove all Video Weaver URLs from known URLs. const videoWeaverUrls = responseBody
responseBody.split("\n").forEach(line => { .split("\n")
if (line.includes("video-weaver.")) { .filter(line => videoWeaverUrlRegex.test(line));
knownVideoWeaverUrls.delete(line.trim()); // 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. // 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>) { 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"); const accept = getHeaderFromMap(headersMap, "Accept");
setHeaderToMap(headersMap, "Accept", `${accept || ""}${acceptFlag}`); 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); 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 { window.Worker = class Worker extends window.Worker {
constructor(scriptURL: string | URL, options?: WorkerOptions) { constructor(scriptURL: string | URL, options?: WorkerOptions) {
const url = scriptURL.toString(); const url = scriptURL.toString();
@ -25,6 +23,11 @@ window.Worker = class Worker extends window.Worker {
); );
script = `importScripts("${url}");`; // Will fail on Firefox Nightly. 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 = ` const newScript = `
try { try {
importScripts("${params.workerScriptURL}"); importScripts("${params.workerScriptURL}");
@ -37,6 +40,11 @@ window.Worker = class Worker extends window.Worker {
new Blob([newScript], { type: "text/javascript" }) new Blob([newScript], { type: "text/javascript" })
); );
super(newScriptURL, options); 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."); 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); setProxyStatus(channelNameLower, status);
setWhitelistStatus(channelNameLower); setWhitelistStatus(channelNameLower);
streamStatusElement.style.display = "flex"; streamStatusElement.style.display = "flex";
if (isChromium) {
whitelistStatusElement.style.display = "none";
}
} else { } else {
streamStatusElement.style.display = "none"; streamStatusElement.style.display = "none";
} }
@ -80,6 +83,7 @@ function setProxyStatus(channelNameLower: string, status: StreamStatus) {
proxiedElement.classList.add("success"); proxiedElement.classList.add("success");
} else if ( } else if (
!status.proxied && !status.proxied &&
status.proxyHost &&
store.state.optimizedProxiesEnabled && store.state.optimizedProxiesEnabled &&
store.state.optimizedProxies.length > 0 store.state.optimizedProxies.length > 0
) { ) {

View File

@ -2,11 +2,13 @@ import isChromium from "../common/ts/isChromium";
import type { State } from "./types"; import type { State } from "./types";
export default function getDefaultState() { export default function getDefaultState() {
return { const state: State = {
adLog: [], adLog: [],
adLogEnabled: true, adLogEnabled: true,
adLogLastSent: 0, adLogLastSent: 0,
dnsResponses: [],
normalProxies: isChromium ? ["chrome.api.cdn-perfprod.com:4023"] : [], normalProxies: isChromium ? ["chrome.api.cdn-perfprod.com:4023"] : [],
openedTwitchTabs: [],
optimizedProxies: isChromium ? [] : ["firefox.api.cdn-perfprod.com:2023"], optimizedProxies: isChromium ? [] : ["firefox.api.cdn-perfprod.com:2023"],
optimizedProxiesEnabled: !isChromium, optimizedProxiesEnabled: !isChromium,
proxyTwitchWebpage: false, proxyTwitchWebpage: false,
@ -14,5 +16,6 @@ export default function getDefaultState() {
streamStatuses: {}, streamStatuses: {},
videoWeaverUrlsByChannel: {}, videoWeaverUrlsByChannel: {},
whitelistedChannels: [], 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 EventType = "load" | "change";
export type ReadyState = "loading" | "complete"; export type ReadyState = "loading" | "complete";
@ -8,7 +8,9 @@ export interface State {
adLog: AdLogEntry[]; adLog: AdLogEntry[];
adLogEnabled: boolean; adLogEnabled: boolean;
adLogLastSent: number; adLogLastSent: number;
dnsResponses: DnsResponse[];
normalProxies: string[]; normalProxies: string[];
openedTwitchTabs: number[];
optimizedProxies: string[]; optimizedProxies: string[];
optimizedProxiesEnabled: boolean; optimizedProxiesEnabled: boolean;
proxyTwitchWebpage: boolean; proxyTwitchWebpage: boolean;

View File

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

View File

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