diff --git a/package-lock.json b/package-lock.json index e0ede248..cffddc8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b6a090f4..3c25768f 100644 --- a/package.json +++ b/package.json @@ -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, diff --git a/src/background/background.ts b/src/background/background.ts index 21ae58d4..abcde524 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -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/*"], - }); } diff --git a/src/background/handlers/checkForOpenedTwitchTabs.ts b/src/background/handlers/checkForOpenedTwitchTabs.ts new file mode 100644 index 00000000..ee899578 --- /dev/null +++ b/src/background/handlers/checkForOpenedTwitchTabs.ts @@ -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); + }); +} diff --git a/src/background/handlers/onBeforeUsherRequest.ts b/src/background/handlers/onBeforeUsherRequest.ts deleted file mode 100644 index ecd6c804..00000000 --- a/src/background/handlers/onBeforeUsherRequest.ts +++ /dev/null @@ -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; -} diff --git a/src/background/handlers/onProxyRequest.ts b/src/background/handlers/onProxyRequest.ts index 812caecb..a73bf9f2 100644 --- a/src/background/handlers/onProxyRequest.ts +++ b/src/background/handlers/onProxyRequest.ts @@ -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. ]; } diff --git a/src/background/handlers/onHeadersReceived.ts b/src/background/handlers/onResponseStarted.ts similarity index 67% rename from src/background/handlers/onHeadersReceived.ts rename to src/background/handlers/onResponseStarted.ts index 5b3a6fb8..df121a76 100644 --- a/src/background/handlers/onHeadersReceived.ts +++ b/src/background/handlers/onResponseStarted.ts @@ -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}`; + } } diff --git a/src/background/handlers/onStartupStoreCleanup.ts b/src/background/handlers/onStartupStoreCleanup.ts index 4cb99061..15c88c1e 100644 --- a/src/background/handlers/onStartupStoreCleanup.ts +++ b/src/background/handlers/onStartupStoreCleanup.ts @@ -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 = {}; } diff --git a/src/background/handlers/onTabCreated.ts b/src/background/handlers/onTabCreated.ts new file mode 100644 index 00000000..8fdb39b7 --- /dev/null +++ b/src/background/handlers/onTabCreated.ts @@ -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); + } +} diff --git a/src/background/handlers/onTabRemoved.ts b/src/background/handlers/onTabRemoved.ts new file mode 100644 index 00000000..26c81d66 --- /dev/null +++ b/src/background/handlers/onTabRemoved.ts @@ -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(); + } + } +} diff --git a/src/background/handlers/onTabUpdated.ts b/src/background/handlers/onTabUpdated.ts new file mode 100644 index 00000000..26527f00 --- /dev/null +++ b/src/background/handlers/onTabUpdated.ts @@ -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(); + } + } + } +} diff --git a/src/common/ts/file.ts b/src/common/ts/file.ts new file mode 100644 index 00000000..64a556ca --- /dev/null +++ b/src/common/ts/file.ts @@ -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((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(); +} diff --git a/src/common/ts/findChannelFromUsherUrl.ts b/src/common/ts/findChannelFromUsherUrl.ts index 4fb94621..096d35da 100644 --- a/src/common/ts/findChannelFromUsherUrl.ts +++ b/src/common/ts/findChannelFromUsherUrl.ts @@ -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 { diff --git a/src/common/ts/findChannelFromVideoWeaverUrl.ts b/src/common/ts/findChannelFromVideoWeaverUrl.ts index 0cfcfdf7..53eb2f2f 100644 --- a/src/common/ts/findChannelFromVideoWeaverUrl.ts +++ b/src/common/ts/findChannelFromVideoWeaverUrl.ts @@ -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 => diff --git a/src/common/ts/updateProxySettings.ts b/src/common/ts/proxySettings.ts similarity index 85% rename from src/common/ts/updateProxySettings.ts rename to src/common/ts/proxySettings.ts index b8cde82e..e6fa360f 100644 --- a/src/common/ts/updateProxySettings.ts +++ b/src/common/ts/proxySettings.ts @@ -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() || ""}` ); + 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"); + }); +} diff --git a/src/common/ts/readFile.ts b/src/common/ts/readFile.ts deleted file mode 100644 index 754aa14c..00000000 --- a/src/common/ts/readFile.ts +++ /dev/null @@ -1,15 +0,0 @@ -export default async function readFile(accept = "text/plain;charset=utf-8") { - return new Promise((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(); - }); -} diff --git a/src/common/ts/saveFile.ts b/src/common/ts/saveFile.ts deleted file mode 100644 index 0188a2fd..00000000 --- a/src/common/ts/saveFile.ts +++ /dev/null @@ -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(); -} diff --git a/src/common/ts/streamStatus.ts b/src/common/ts/streamStatus.ts new file mode 100644 index 00000000..b13d7727 --- /dev/null +++ b/src/common/ts/streamStatus.ts @@ -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; +} diff --git a/src/common/ts/updateDnsResponses.ts b/src/common/ts/updateDnsResponses.ts new file mode 100644 index 00000000..dcc1b1c7 --- /dev/null +++ b/src/common/ts/updateDnsResponses.ts @@ -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); +} diff --git a/src/content/content.ts b/src/content/content.ts index 1aaf13ed..26233598 100644 --- a/src/content/content.ts +++ b/src/content/content.ts @@ -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, + }); + } +} diff --git a/src/manifest.chromium.json b/src/manifest.chromium.json index 96e224d5..a520cd0d 100644 --- a/src/manifest.chromium.json +++ b/src/manifest.chromium.json @@ -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" ], diff --git a/src/manifest.firefox.json b/src/manifest.firefox.json index 6b144ad5..7e7db52a 100644 --- a/src/manifest.firefox.json +++ b/src/manifest.firefox.json @@ -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" } diff --git a/src/options/options.ts b/src/options/options.ts index ad7db528..0c69891e 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -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", diff --git a/src/page/getFetch.ts b/src/page/getFetch.ts index 0203da62..600e506c 100644 --- a/src/page/getFetch.ts +++ b/src/page/getFetch.ts @@ -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(); const videoWeaverUrlsToFlag = new Map(); // Video Weaver URLs to flag -> number of times flagged. const videoWeaverUrlsToIgnore = new Set(); // 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, 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) { + 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}`); } diff --git a/src/page/page.ts b/src/page/page.ts index 4d886386..d400409d 100644 --- a/src/page/page.ts +++ b/src/page/page.ts @@ -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); + } + }); } }; diff --git a/src/page/worker.ts b/src/page/worker.ts index 82d3a4da..ceab7053 100644 --- a/src/page/worker.ts +++ b/src/page/worker.ts @@ -2,4 +2,4 @@ import { getFetch } from "./getFetch"; console.info("[TTV LOL PRO] πŸš€ Worker script running."); -self.fetch = getFetch(); +self.fetch = getFetch({ scope: "worker" }); diff --git a/src/popup/popup.ts b/src/popup/popup.ts index 55f7db21..4930262a 100644 --- a/src/popup/popup.ts +++ b/src/popup/popup.ts @@ -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 ) { diff --git a/src/rules/rules.json b/src/rulesets/ruleset.json similarity index 100% rename from src/rules/rules.json rename to src/rulesets/ruleset.json diff --git a/src/store/getDefaultState.ts b/src/store/getDefaultState.ts index cc571f83..828c60a0 100644 --- a/src/store/getDefaultState.ts +++ b/src/store/getDefaultState.ts @@ -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; } diff --git a/src/store/types.ts b/src/store/types.ts index 8b172e53..8062c83f 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -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; diff --git a/src/types.ts b/src/types.ts index 0e62dce2..b2d3a7dd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,3 +43,10 @@ export interface StreamStatus { notProxied: number; }; } + +export interface DnsResponse { + host: string; + ips: string[]; + timestamp: number; + ttl: number; +} diff --git a/tsconfig.json b/tsconfig.json index 788ccd22..9b779d80 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "target": "ES2020", "moduleResolution": "node", - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "noEmit": true } }