From 397c19555678b12fc33c5e88a250d9a7c9bf0e71 Mon Sep 17 00:00:00 2001 From: Rafael Gomes Date: Sun, 7 May 2023 18:49:32 -0300 Subject: [PATCH] Make compatible with Manifest v3 (#256) * fix: browser storage version types * fix: auth flow on firefox * fix: scrobbling details do not update if scrobbling from a different tab * fix: scrobbling item image does not appear in popup * fix: history requests are not canceled * fix: canceled requests do not throw error * feat: change manifest version * feat: add helper to check manifest version * feat: change background script to service worker * feat: separate permissions from host permissions * feat: remove content security policy * feat: rename browser action to action * feat: remove web accessible resources * feat: use scripting api to inject content scripts * feat: use scripting api to inject functions * feat: remove ports * feat: remove axios * feat: remove window/document references from background logic --- package.json | 2 - pnpm-lock.yaml | 30 ---- src/apis/ServiceApi.ts | 7 +- src/apis/TraktApi.ts | 14 +- src/apis/TraktAuth.ts | 13 +- src/apis/TraktScrobble.ts | 2 +- src/apis/TraktSync.ts | 2 - src/common/AutoSync.ts | 2 +- src/common/BrowserAction.ts | 34 ++-- src/common/BrowserStorage.ts | 2 +- src/common/Errors.ts | 8 +- src/common/Messaging.ts | 75 +++++--- src/common/Requests.ts | 163 +++++------------- src/common/RequestsManager.ts | 2 - src/common/ScriptInjector.ts | 146 +++++++++++----- src/common/ScrobbleParser.ts | 36 ++-- src/common/SessionStorage.ts | 26 +++ src/common/Shared.ts | 6 + src/global.d.ts | 2 + src/modules/background/background.ts | 9 +- src/modules/content/service/service.ts | 9 +- src/modules/history/history.tsx | 5 +- src/modules/options/options.tsx | 5 +- .../popup/components/PopupWatching.tsx | 2 +- src/modules/popup/popup.tsx | 5 +- src/services/amazon-prime/AmazonPrimeApi.ts | 3 +- src/services/amc-plus/AmcPlusApi.ts | 31 ++-- src/services/crunchyroll/CrunchyrollApi.ts | 3 +- src/services/hbo-max/HboMaxApi.ts | 46 ++--- src/services/mubi/MubiApi.ts | 3 +- src/services/netflix/NetflixApi.ts | 31 ++-- src/services/nrk/NrkApi.ts | 16 +- src/services/nrk/NrkParser.ts | 13 +- src/services/viaplay/ViaplayApi.ts | 3 +- webpack.config.ts | 101 +++++++---- 35 files changed, 452 insertions(+), 405 deletions(-) create mode 100644 src/common/SessionStorage.ts diff --git a/package.json b/package.json index c64ebba6..a8f2013c 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,6 @@ "@mui/material": "^5.10.16", "@mui/system": "^5.10.16", "@mui/x-date-pickers": "^5.0.9", - "@rafaelgomesxyz/axios-rate-limit": "^1.3.1", - "axios": "^0.26.1", "date-fns": "^2.29.3", "deepmerge": "^4.2.2", "history": "^4.10.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a31f5b30..6d44798a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,7 +17,6 @@ specifiers: '@mui/system': ^5.10.16 '@mui/x-date-pickers': ^5.0.9 '@octokit/rest': ^19.0.5 - '@rafaelgomesxyz/axios-rate-limit': ^1.3.1 '@trakt-tools/cli': ^0.3.3 '@types/archiver': ^5.3.1 '@types/circular-dependency-plugin': ^5.0.5 @@ -35,7 +34,6 @@ specifiers: '@typescript-eslint/eslint-plugin': ^5.45.0 '@typescript-eslint/parser': ^5.45.0 archiver: ^5.3.1 - axios: ^0.26.1 babel-loader: ^9.1.0 babel-preset-minify: ^0.5.2 circular-dependency-plugin: ^5.2.2 @@ -85,8 +83,6 @@ dependencies: '@mui/material': 5.10.16_dcb5dvvtsx36mkhj3zatp75czu '@mui/system': 5.10.16_744ksytjc5r2ljn2ycwqyqkjji '@mui/x-date-pickers': 5.0.9_5jl2rg5of4rymsbngywpgdy2vy - '@rafaelgomesxyz/axios-rate-limit': 1.3.1_axios@0.26.1 - axios: 0.26.1 date-fns: 2.29.3 deepmerge: 4.2.2 history: 4.10.1 @@ -2159,14 +2155,6 @@ packages: resolution: {integrity: sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==} dev: false - /@rafaelgomesxyz/axios-rate-limit/1.3.1_axios@0.26.1: - resolution: {integrity: sha512-eSLiUDBvaR93CS23pWCy4elPzWV9w6W65IJFrNT3Pg4ay8XYAxo45goBl3fxV0fKcXGglIKl/2z1osspKMGHwg==} - peerDependencies: - axios: '*' - dependencies: - axios: 0.26.1 - dev: false - /@trakt-tools/cli/0.3.3: resolution: {integrity: sha512-EsR3W6mOCojii3KqL7hjAwq8Rv8GgFi7H/awEEKEUKjzFmy82wvUGNcjrdshHQMp450oM/NvNd5AVbdRVMi/vQ==} hasBin: true @@ -2846,14 +2834,6 @@ packages: /async/3.2.4: resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==} - /axios/0.26.1: - resolution: {integrity: sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==} - dependencies: - follow-redirects: 1.14.9 - transitivePeerDependencies: - - debug - dev: false - /babel-helper-evaluate-path/0.5.0: resolution: {integrity: sha512-mUh0UhS607bGh5wUMAQfOpt2JX2ThXMtppHRdRU1kL7ZLRWIXxoV2UIV1r2cAeeNeU1M5SB5/RSUgUxrK8yOkA==} dev: true @@ -4135,16 +4115,6 @@ packages: resolution: {integrity: sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==} dev: true - /follow-redirects/1.14.9: - resolution: {integrity: sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - dev: false - /fs-constants/1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} dev: true diff --git a/src/apis/ServiceApi.ts b/src/apis/ServiceApi.ts index 5cd52968..0154a919 100644 --- a/src/apis/ServiceApi.ts +++ b/src/apis/ServiceApi.ts @@ -136,7 +136,8 @@ export abstract class ServiceApi { async loadHistory( itemsToLoad: number, lastSync: number, - lastSyncId: string + lastSyncId: string, + cancelKey = 'default' ): Promise { let items: ScrobbleItem[] = []; try { @@ -156,7 +157,7 @@ export abstract class ServiceApi { responseItems = this.leftoverHistoryItems; this.leftoverHistoryItems = []; } else if (!this.hasReachedHistoryEnd) { - responseItems = await this.loadHistoryItems(); + responseItems = await this.loadHistoryItems(cancelKey); if (!this.hasCheckedHistoryCache) { let firstItem: ScrobbleItemValues | null = null; if (historyCache.items.length > 0) { @@ -282,7 +283,7 @@ export abstract class ServiceApi { * * Should be overridden in the child class. */ - loadHistoryItems(): Promise { + loadHistoryItems(cancelKey = 'default'): Promise { Shared.errors.error('loadHistoryItems() is not implemented in this service!', new Error()); return Promise.resolve([]); } diff --git a/src/apis/TraktApi.ts b/src/apis/TraktApi.ts index dfba29da..648cf8d7 100644 --- a/src/apis/TraktApi.ts +++ b/src/apis/TraktApi.ts @@ -1,4 +1,4 @@ -import { withHeaders, withRateLimit } from '@common/Requests'; +import { Requests, withHeaders } from '@common/Requests'; import { Shared } from '@common/Shared'; export class TraktApi { @@ -15,17 +15,7 @@ export class TraktApi { SYNC_URL: string; SETTINGS_URL: string; - requests = withRateLimit({ - id: 'trakt-api', - - /** - * @see https://trakt.docs.apiary.io/#introduction/rate-limiting - */ - maxRPS: { - '*': 1, - GET: 3, - }, - }); + requests = Requests; isActivated = false; diff --git a/src/apis/TraktAuth.ts b/src/apis/TraktAuth.ts index 7eba7de7..d70c94d2 100644 --- a/src/apis/TraktAuth.ts +++ b/src/apis/TraktAuth.ts @@ -30,6 +30,10 @@ class _TraktAuth extends TraktApi { this.manualAuth = {}; } + requiresCookies(): boolean { + return Shared.browser === 'firefox' ? !!Shared.storage.options.grantCookies : false; + } + getAuthorizeUrl(): string { return `${this.AUTHORIZE_URL}?response_type=code&client_id=${ Shared.clientId @@ -37,8 +41,7 @@ class _TraktAuth extends TraktApi { } getRedirectUrl(): string { - const requiresCookies = !!Shared.storage.options.grantCookies; - return this.isIdentityAvailable && !requiresCookies + return this.isIdentityAvailable && !this.requiresCookies() ? browser.identity.getRedirectURL() : this.REDIRECT_URL; } @@ -54,11 +57,7 @@ class _TraktAuth extends TraktApi { authorize(): Promise { let promise: Promise; - let requiresCookies = false; - if (Shared.browser === 'firefox') { - requiresCookies = !!Shared.storage.options.grantCookies; - } - if (this.isIdentityAvailable && !requiresCookies) { + if (this.isIdentityAvailable && !this.requiresCookies()) { promise = this.startIdentityAuth(); } else { promise = new Promise((resolve) => void this.startManualAuth(resolve)); diff --git a/src/apis/TraktScrobble.ts b/src/apis/TraktScrobble.ts index ee59eb88..31500916 100644 --- a/src/apis/TraktScrobble.ts +++ b/src/apis/TraktScrobble.ts @@ -44,7 +44,7 @@ class _TraktScrobble extends TraktApi { } await this.send(item.trakt, this.START); let { scrobblingDetails } = await Shared.storage.get('scrobblingDetails'); - if (scrobblingDetails) { + if (scrobblingDetails?.tabId === Shared.tabId) { scrobblingDetails.isPaused = false; } else { scrobblingDetails = { diff --git a/src/apis/TraktSync.ts b/src/apis/TraktSync.ts index d5680a51..4afcd2f5 100644 --- a/src/apis/TraktSync.ts +++ b/src/apis/TraktSync.ts @@ -1,6 +1,5 @@ import { TraktApi } from '@apis/TraktApi'; import { Cache, CacheItem } from '@common/Cache'; -import { RequestPriority } from '@common/Requests'; import { Shared } from '@common/Shared'; import { Utils } from '@common/Utils'; import { ScrobbleItem } from '@models/Item'; @@ -56,7 +55,6 @@ class _TraktSync extends TraktApi { url: this.getUrl(item), method: 'GET', cancelKey, - priority: RequestPriority.HIGH, }); historyItems = JSON.parse(responseText) as TraktHistoryItem[]; traktHistoryItemsCache.set(databaseId, historyItems); diff --git a/src/common/AutoSync.ts b/src/common/AutoSync.ts index 28177c9a..ae6c6b84 100644 --- a/src/common/AutoSync.ts +++ b/src/common/AutoSync.ts @@ -93,7 +93,7 @@ class _AutoSync { await store.resetData(); try { - await api.loadHistory(Infinity, serviceValue.lastSync, serviceValue.lastSyncId); + await api.loadHistory(Infinity, serviceValue.lastSync, serviceValue.lastSyncId, 'autoSync'); items = store.data.items.filter( (item) => item.progress >= Shared.storage.syncOptions.minPercentageWatched diff --git a/src/common/BrowserAction.ts b/src/common/BrowserAction.ts index 701dfaa0..5bb956ca 100644 --- a/src/common/BrowserAction.ts +++ b/src/common/BrowserAction.ts @@ -1,16 +1,18 @@ import { Messaging } from '@common/Messaging'; +import { Requests } from '@common/Requests'; import { Shared } from '@common/Shared'; import browser, { Action as WebExtAction } from 'webextension-polyfill'; export interface BrowserActionRotating { - image: HTMLImageElement | null; - canvas: HTMLCanvasElement | null; - context: CanvasRenderingContext2D | null; + image: ImageBitmap | null; + canvas: OffscreenCanvas | null; + context: OffscreenCanvasRenderingContext2D | null; degrees: number; canceled: boolean; } class _BrowserAction { + instance = Shared.manifestVersion === 3 ? browser.action : browser.browserAction; currentIcon = browser.runtime.getURL('images/uts-icon-38.png'); rotating: BrowserActionRotating | null = null; @@ -30,7 +32,7 @@ class _BrowserAction { async setTitle(title = 'Universal Trakt Scrobbler'): Promise { if (Shared.pageType === 'background') { - await browser.browserAction.setTitle({ title }); + await this.instance.setTitle({ title }); } else { await Messaging.toExtension({ action: 'set-title', title }); } @@ -43,7 +45,7 @@ class _BrowserAction { await this.setStaticIcon(); await this.setRotatingIcon(); } else { - await browser.browserAction.setIcon({ + await this.instance.setIcon({ path: this.currentIcon, }); } @@ -59,7 +61,7 @@ class _BrowserAction { await this.setStaticIcon(); await this.setRotatingIcon(); } else { - await browser.browserAction.setIcon({ + await this.instance.setIcon({ path: this.currentIcon, }); } @@ -70,9 +72,16 @@ class _BrowserAction { async setRotatingIcon(): Promise { if (Shared.pageType === 'background') { - const image = document.createElement('img'); - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); + const imageResponse = await Requests.fetch({ + method: 'GET', + url: this.currentIcon, + }); + const imageBlob = await imageResponse.blob(); + const image = await createImageBitmap(imageBlob); + const canvas = new OffscreenCanvas(image.width, image.height); + const context = canvas.getContext('2d', { + willReadFrequently: true, + }) as OffscreenCanvasRenderingContext2D; this.rotating = { image, canvas, @@ -80,8 +89,7 @@ class _BrowserAction { degrees: 0, canceled: false, }; - image.onload = () => void this.rotateIcon(); - image.src = this.currentIcon; + await this.rotateIcon(); } else { await Messaging.toExtension({ action: 'set-rotating-icon' }); } @@ -114,7 +122,7 @@ class _BrowserAction { context.rotate((degrees * Math.PI) / 180); context.drawImage(image, -(image.width / 2), -(image.height / 2)); - await browser.browserAction.setIcon({ + await this.instance.setIcon({ imageData: context.getImageData( 0, 0, @@ -133,7 +141,7 @@ class _BrowserAction { this.rotating.degrees = 0; } - window.setTimeout(() => void this.rotateIcon(), 30); + setTimeout(() => void this.rotateIcon(), 30); } } diff --git a/src/common/BrowserStorage.ts b/src/common/BrowserStorage.ts index dd1016b6..88466d3e 100644 --- a/src/common/BrowserStorage.ts +++ b/src/common/BrowserStorage.ts @@ -22,7 +22,7 @@ export type StorageValuesV11 = Omit & { version?: 11; }; -export type StorageValuesV10 = Omit & { +export type StorageValuesV10 = Omit & { version?: 10; options?: StorageValuesOptionsV4; }; diff --git a/src/common/Errors.ts b/src/common/Errors.ts index f68ddd9a..e3b3821a 100644 --- a/src/common/Errors.ts +++ b/src/common/Errors.ts @@ -39,10 +39,14 @@ class _Errors { environment: Shared.environment, }, }); - window.Rollbar = this.rollbar; + if (window) { + window.Rollbar = this.rollbar; + } } else if (!allowRollbar && this.rollbar) { delete this.rollbar; - delete window.Rollbar; + if (window) { + delete window.Rollbar; + } } } diff --git a/src/common/Messaging.ts b/src/common/Messaging.ts index 4646a8ba..c725e4da 100644 --- a/src/common/Messaging.ts +++ b/src/common/Messaging.ts @@ -2,6 +2,7 @@ import { TraktAuthDetails } from '@apis/TraktAuth'; import { Event, EventData } from '@common/Events'; import { RequestDetails } from '@common/Requests'; import { RequestError, RequestErrorOptions } from '@common/RequestError'; +import { SessionStorage } from '@common/SessionStorage'; import { Shared } from '@common/Shared'; import { TabProperties } from '@common/Tabs'; import browser, { Runtime as WebExtRuntime, Tabs as WebExtTabs } from 'webextension-polyfill'; @@ -9,6 +10,7 @@ import browser, { Runtime as WebExtRuntime, Tabs as WebExtTabs } from 'webextens export type MessageRequest = MessageRequests[keyof MessageRequests]; export interface MessageRequests { + 'connect-content-script': ConnectContentScriptMessage; 'open-tab': OpenTabMessage; 'get-tab-id': GetTabIdMessage; 'validate-trakt-token': ValidateTraktTokenMessage; @@ -25,11 +27,13 @@ export interface MessageRequests { 'dispatch-event': DispatchEventMessage; 'send-to-all-content': SendToAllContentMessage; 'inject-function': InjectFunctionMessage; + 'inject-function-from-background': InjectFunctionFromBackgroundMessage; } export type ReturnType = ReturnTypes[T['action']]; export interface ReturnTypes { + 'connect-content-script': void; 'open-tab': WebExtTabs.Tab | null; 'get-tab-id': number | null; 'validate-trakt-token': TraktAuthDetails | null; @@ -46,6 +50,11 @@ export interface ReturnTypes { 'dispatch-event': void; 'send-to-all-content': void; 'inject-function': unknown; + 'inject-function-from-background': unknown; +} + +export interface ConnectContentScriptMessage { + action: 'connect-content-script'; } export interface OpenTabMessage { @@ -124,8 +133,12 @@ export interface InjectFunctionMessage { serviceId: string; key: string; url: string; - fnStr: string; - fnParamsStr: string; + params: Record; +} + +export interface InjectFunctionFromBackgroundMessage extends Omit { + action: 'inject-function-from-background'; + tabId: number | null; } export type MessageHandlers = { @@ -156,29 +169,34 @@ class _Messaging { Shared.events.dispatch(message.eventType, message.eventSpecifier, message.data, true), }; - ports = new Map(); - init() { - if (Shared.pageType === 'background') { - browser.runtime.onConnect.addListener(this.onConnect); - } else if (Shared.pageType === 'content') { - browser.runtime.connect(); + if (Shared.pageType === 'content') { + void this.toExtension({ action: 'connect-content-script' }); } - browser.runtime.onMessage.addListener(this.onMessage); } - private onConnect = (port: WebExtRuntime.Port) => { - const tabId = port.sender?.tab?.id; - if (!tabId) { - return; + addListeners() { + if ( + Shared.pageType === 'background' && + !browser.tabs.onRemoved.hasListener(this.onTabRemoved) + ) { + browser.tabs.onRemoved.addListener(this.onTabRemoved); } + browser.runtime.onMessage.addListener(this.onMessage); + } - this.ports.set(tabId, port); - void Shared.events.dispatch('CONTENT_SCRIPT_CONNECT', null, { tabId }); + async connectContentScript(message: ConnectContentScriptMessage, tabId: number | null) { + if (tabId !== null) { + await Shared.events.dispatch('CONTENT_SCRIPT_CONNECT', null, { tabId }); + } + } - port.onDisconnect.addListener(() => { - this.ports.delete(tabId); - void Shared.events.dispatch('CONTENT_SCRIPT_DISCONNECT', null, { tabId }); + private onTabRemoved = (tabId: number) => { + void SessionStorage.get('injectedContentScriptTabs').then((values) => { + const injectedContentScriptTabs = new Set(values.injectedContentScriptTabs ?? []); + if (injectedContentScriptTabs.has(tabId)) { + void Shared.events.dispatch('CONTENT_SCRIPT_DISCONNECT', null, { tabId }); + } }); }; @@ -241,18 +259,19 @@ class _Messaging { response = ((await browser.runtime.sendMessage(message)) ?? null) as ReturnType; } catch (err) { if (err instanceof Error) { + let messagingError; try { - const messagingError = JSON.parse(err.message) as MessagingError; - switch (messagingError.instance) { - case 'Error': - throw new Error(messagingError.data.message); - - case 'RequestError': - throw new RequestError(messagingError.data); - } + messagingError = JSON.parse(err.message) as MessagingError; } catch (_) { throw err; } + switch (messagingError.instance) { + case 'Error': + throw new Error(messagingError.data.message); + + case 'RequestError': + throw new RequestError(messagingError.data); + } } } return response; @@ -279,7 +298,9 @@ class _Messaging { }); } - for (const tabId of this.ports.keys()) { + const values = await SessionStorage.get('injectedContentScriptTabs'); + const injectedContentScriptTabs = new Set(values.injectedContentScriptTabs ?? []); + for (const tabId of injectedContentScriptTabs) { await this.toContent(message, tabId); } } diff --git a/src/common/Requests.ts b/src/common/Requests.ts index c0f195d9..dfe132e0 100644 --- a/src/common/Requests.ts +++ b/src/common/Requests.ts @@ -2,20 +2,16 @@ import { Messaging } from '@common/Messaging'; import { RequestError } from '@common/RequestError'; import { RequestsManager } from '@common/RequestsManager'; import { Shared } from '@common/Shared'; -import axiosRateLimit from '@rafaelgomesxyz/axios-rate-limit'; -import axios, { AxiosResponse, Method } from 'axios'; import browser from 'webextension-polyfill'; export type RequestDetails = { url: string; method: string; headers?: Record; - rateLimit?: RateLimit; body?: unknown; + signal?: AbortSignal; cancelKey?: string; - priority?: RequestPriority; withHeaders?: Record; - withRateLimit?: RateLimitConfig; }; export interface RequestOptions { @@ -24,59 +20,41 @@ export interface RequestOptions { body: RequestInit['body']; } -export interface RateLimit { - id: string; - maxRPS: number; -} - -export enum RequestPriority { - NORMAL, - HIGH, -} - -export interface RateLimitConfig { - /** All requests with the same ID will be limited by the same instance. */ - id: string; - - /** Maximum requests per second. */ - maxRPS: { - /** This limit will apply to all methods, unless a limit for the specific method has been provided. */ - '*': number; - - /** This limit will apply to the specific method. */ - [K: string]: number | undefined; - }; -} - class _Requests { readonly withHeaders: Record = {}; - readonly withRateLimit: RateLimitConfig = { - id: 'default', - maxRPS: { - '*': 2, - }, - }; async send(request: RequestDetails, tabId = Shared.tabId): Promise { - let responseText = ''; - if (Shared.pageType === 'background') { - responseText = await this.sendDirectly(request, tabId); - } else { - // All requests from other pages must be sent to the background page so that it can rate limit them - request.withHeaders = this.withHeaders; - request.withRateLimit = this.withRateLimit; - responseText = await Messaging.toExtension({ action: 'send-request', request }); - } - return responseText; + return new Promise((resolve, reject) => { + if (Shared.pageType === 'background') { + void this.sendDirectly(request, tabId, resolve, reject); + } else { + // All requests from other pages must be sent to the background page to bypass CORS + request.withHeaders = this.withHeaders; + Messaging.toExtension({ action: 'send-request', request }).then(resolve).catch(reject); + } + }); } - async sendDirectly(request: RequestDetails, tabId = Shared.tabId): Promise { + async sendDirectly( + request: RequestDetails, + tabId = Shared.tabId, + resolve: PromiseResolve, + reject: PromiseReject + ): Promise { let responseStatus = 0; let responseText = ''; try { const response = await this.fetch(request, tabId); responseStatus = response.status; - responseText = response.data; + responseText = await response.text(); + if (responseStatus === 429) { + const retryAfterStr = response.headers.get('Retry-After'); + if (retryAfterStr) { + const retryAfter = Number.parseInt(retryAfterStr) * 1000; + setTimeout(() => void this.sendDirectly(request, tabId, resolve, reject), retryAfter); + return; + } + } if (responseStatus < 200 || responseStatus >= 400) { throw responseText; } @@ -94,42 +72,32 @@ class _Requests { if ('status' in err.response) errRespStatus = err.response.status as number; if ('data' in err.response) errRespData = err.response.data as string; } - throw new RequestError({ - request, - status: errRespStatus, - text: errRespData, - isCanceled: err instanceof axios.Cancel, - }); + reject( + new RequestError({ + request, + status: errRespStatus, + text: errRespData, + isCanceled: request.signal?.aborted ?? false, + }) + ); } - return responseText; + resolve(responseText); } - async fetch(request: RequestDetails, tabId = Shared.tabId): Promise> { + async fetch(request: RequestDetails, tabId = Shared.tabId): Promise { const options = await this.getOptions(request, tabId); + const cancelKey = `${tabId !== null ? `${tabId}_` : ''}${request.cancelKey || 'default'}`; if (!RequestsManager.abortControllers.has(cancelKey)) { RequestsManager.abortControllers.set(cancelKey, new AbortController()); } - const signal = RequestsManager.abortControllers.get(cancelKey)?.signal; - - const rateLimit = request.rateLimit ?? this.getRateLimit(request); - let instance = RequestsManager.instances.get(rateLimit.id); - if (!instance) { - instance = axiosRateLimit(axios.create(), { maxRPS: rateLimit.maxRPS }); - RequestsManager.instances.set(rateLimit.id, instance); - } + request.signal = RequestsManager.abortControllers.get(cancelKey)?.signal; - return instance.request({ - url: request.url, - method: options.method as Method, + return fetch(request.url, { + method: options.method, headers: options.headers, - data: options.body, - responseType: 'text', - signal, - transformResponse: (res: string) => res, - - // @ts-expect-error Custom prop - priority: request.priority || RequestPriority.NORMAL, + body: options.body, + signal: request.signal, }); } @@ -175,41 +143,6 @@ class _Requests { }); return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; '); } - - getRateLimit(request: RequestDetails) { - let id; - let maxRPS; - - if (request.withRateLimit) { - id = request.withRateLimit.id; - maxRPS = request.withRateLimit.maxRPS[request.method]; - - if (maxRPS) { - return { - id: `${id}_${request.method}`, - maxRPS, - }; - } - - maxRPS = request.withRateLimit.maxRPS['*']; - - return { id, maxRPS }; - } - - id = this.withRateLimit.id; - maxRPS = this.withRateLimit.maxRPS[request.method]; - - if (maxRPS) { - return { - id: `${id}_${request.method}`, - maxRPS, - }; - } - - maxRPS = this.withRateLimit.maxRPS['*']; - - return { id, maxRPS }; - } } export const Requests = new _Requests(); @@ -227,17 +160,3 @@ export const withHeaders = (headers: Record, instance = Requests }, }); }; - -/** - * Creates a proxy to a requests instance that uses the provided rate limit. Useful for making requests without having to provide the rate limit every time. - */ -export const withRateLimit = (rateLimit: RateLimitConfig, instance = Requests) => { - return new Proxy(instance, { - get: (target, prop, receiver) => { - if (prop === 'withRateLimit') { - return { ...instance.withRateLimit, ...rateLimit }; - } - return Reflect.get(target, prop, receiver) as unknown; - }, - }); -}; diff --git a/src/common/RequestsManager.ts b/src/common/RequestsManager.ts index 72236206..66e146c5 100644 --- a/src/common/RequestsManager.ts +++ b/src/common/RequestsManager.ts @@ -1,12 +1,10 @@ import { RequestsCancelData, StorageOptionsChangeData } from '@common/Events'; import { Shared } from '@common/Shared'; import { getServices } from '@models/Service'; -import { AxiosInstance } from 'axios'; import browser, { WebRequest as WebExtWebRequest } from 'webextension-polyfill'; class _RequestsManager { abortControllers = new Map(); - instances = new Map(); init() { if (Shared.pageType === 'background') { diff --git a/src/common/ScriptInjector.ts b/src/common/ScriptInjector.ts index e7a835a1..3abdf016 100644 --- a/src/common/ScriptInjector.ts +++ b/src/common/ScriptInjector.ts @@ -1,6 +1,7 @@ import { TraktScrobble } from '@apis/TraktScrobble'; import { ContentScriptConnectData, StorageOptionsChangeData } from '@common/Events'; import { Messaging } from '@common/Messaging'; +import { SessionStorage } from '@common/SessionStorage'; import { Shared } from '@common/Shared'; import { Tabs } from '@common/Tabs'; import { getServices } from '@models/Service'; @@ -15,7 +16,6 @@ export interface ScriptInjectorMessage { class _ScriptInjector { contentScripts: WebExtManifest.ContentScript[] | null = null; - injectedContentScriptTabs = new Set(); injectedScriptIds = new Set(); init() { @@ -40,8 +40,13 @@ class _ScriptInjector { }; private onContentScriptDisconnect = async (data: ContentScriptConnectData) => { - if (this.injectedContentScriptTabs.has(data.tabId)) { - this.injectedContentScriptTabs.delete(data.tabId); + const values = await SessionStorage.get('injectedContentScriptTabs'); + const injectedContentScriptTabs = new Set(values.injectedContentScriptTabs ?? []); + if (injectedContentScriptTabs.has(data.tabId)) { + injectedContentScriptTabs.delete(data.tabId); + await SessionStorage.set({ + injectedContentScriptTabs: Array.from(injectedContentScriptTabs), + }); } const { scrobblingDetails } = await Shared.storage.get('scrobblingDetails'); if (scrobblingDetails && data.tabId === scrobblingDetails.tabId) { @@ -61,7 +66,6 @@ class _ScriptInjector { hostPattern.replace(/^\*:\/\/\*\./, 'https?:\\/\\/([^/]*\\.)?').replace(/\/\*$/, '') ), js: [`${service.id}.js`], - run_at: 'document_idle', })); } @@ -78,30 +82,55 @@ class _ScriptInjector { } } - onTabUpdated = (_: unknown, __: unknown, tab: WebExtTabs.Tab) => { - void this.injectContentScript(tab); + onTabUpdated = ( + _: unknown, + changeInfo: WebExtTabs.OnUpdatedChangeInfoType, + tab: WebExtTabs.Tab + ) => { + void this.injectContentScript(changeInfo, tab); }; - async injectContentScript(tab: Partial) { + async injectContentScript( + changeInfo: WebExtTabs.OnUpdatedChangeInfoType, + tab: Partial + ) { + const values = await SessionStorage.get('injectedContentScriptTabs'); + const injectedContentScriptTabs = new Set(values.injectedContentScriptTabs ?? []); + if ( + tab.id && + injectedContentScriptTabs.has(tab.id) && + changeInfo.status === 'loading' && + typeof changeInfo.url === 'undefined' + ) { + void Shared.events.dispatch('CONTENT_SCRIPT_DISCONNECT', null, { tabId: tab.id }); + return; + } if ( !this.contentScripts || tab.status !== 'complete' || !tab.id || !tab.url || !tab.url.startsWith('http') || - this.injectedContentScriptTabs.has(tab.id) + injectedContentScriptTabs.has(tab.id) ) { return; } - for (const { matches, js, run_at: runAt } of this.contentScripts) { - if (!js || !runAt) { + for (const { matches, js } of this.contentScripts) { + if (!js) { continue; } const isMatch = matches.find((match) => tab.url?.match(match)); if (isMatch) { - this.injectedContentScriptTabs.add(tab.id); + injectedContentScriptTabs.add(tab.id); + await SessionStorage.set({ + injectedContentScriptTabs: Array.from(injectedContentScriptTabs), + }); for (const file of js) { - await browser.tabs.executeScript(tab.id, { file, runAt }); + if (Shared.manifestVersion === 3) { + await browser.scripting.executeScript({ target: { tabId: tab.id }, files: [file] }); + } else { + await browser.tabs.executeScript(tab.id, { file }); + } } break; } @@ -109,29 +138,35 @@ class _ScriptInjector { } /** - * @param serviceId - * @param key This should be unique for `serviceId`. - * @param url If the content page isn't open, this URL will be used to open a tab in the background, so that the script can be injected. If an empty string is provided, `null` will be returned instead. - * - * @param fn The function that will be injected. It will be injected by converting it to a string (with `Function.prototype.toString`), so it should not contain any references to outside variables/functions, because they will not be available in the injected script. + * The function that will be injected must be added to the `{serviceId}-{key}` key in `Shared.functionsToInject` e.g. `netflix-session`. It should not contain any references to outside variables/functions, because they will not be available in the injected script. * * It should also not contain any syntax that gets transpiled by Babel, because Babel's helpers will not be available either. A good way to test it is to go to https://babeljs.io/repl#?browsers=&presets=typescript,env&externalPlugins=@babel/plugin-transform-runtime@7.15.0, paste the function and see if it adds Babel helpers at the top. If it does, try using older syntax that doesn't require polyfill. * - * @param fnParams If outside values are needed, they should be passed here. The object will be converted to a string with `JSON.stringify`. + * @param serviceId + * @param key This should be unique for `serviceId`. + * @param url If the content page isn't open, this URL will be used to open a tab in the background, so that the script can be injected. If an empty string is provided, `null` will be returned instead. * + * @param params If outside values are needed, they should be added to this object. It will be passed as the first argument to the function. * @returns */ - inject = Record>( + inject( serviceId: string, key: string, url: string, - fn: ((params: U) => T | null) | string, - fnParams: U | string = '' + params: Record = {} ): Promise { - const fnStr = typeof fn === 'function' ? fn.toString() : fn; - const fnParamsStr = typeof fnParams === 'object' ? JSON.stringify(fnParams) : fnParams; - if (Shared.pageType !== 'content') { - return this.injectInTab(serviceId, key, url, fnStr, fnParamsStr); + return this.injectInTab(serviceId, key, url, params); + } + + if (Shared.manifestVersion === 3) { + return Messaging.toExtension({ + action: 'inject-function-from-background', + serviceId, + key, + url, + params, + tabId: Shared.tabId, + }) as Promise; } return new Promise((resolve) => { @@ -139,8 +174,10 @@ class _ScriptInjector { const id = `${serviceId}-${key}`; if (!this.injectedScriptIds.has(id)) { - const idStr = JSON.stringify(id); const scriptFn = this.getScriptFn(); + const idStr = JSON.stringify(id); + const fnStr = Shared.functionsToInject[id].toString(); + const fnParamsStr = JSON.stringify(params); const scriptFnStr = `(${scriptFn.toString()})(${idStr}, ${fnStr}, ${fnParamsStr});`; const script = document.createElement('script'); @@ -165,31 +202,60 @@ class _ScriptInjector { }); } - private async injectInTab( + async injectInTab( serviceId: string, key: string, url: string, - fnStr: string, - fnParamsStr: string + params: Record = {}, + tabId: number | null = null ): Promise { - if (!url) { - return null; - } - return new Promise((resolve) => { - let tabId: number | undefined; + const id = `${serviceId}-${key}`; + + if (Shared.manifestVersion === 3 && tabId !== null) { + void browser.scripting + .executeScript({ + target: { tabId }, + func: Shared.functionsToInject[id], + args: [params], + // @ts-expect-error This is a newer value, so it's missing from the types. + world: 'MAIN', + }) + .then((results) => { + const value = results[0].result as T | null; + resolve(value); + }); + return; + } + + if (!url) { + resolve(null); + return; + } const onScriptConnect = async (data: ContentScriptConnectData) => { if (typeof tabId === 'undefined' || tabId !== data.tabId) { return; } - const value = await Messaging.toContent( - { action: 'inject-function', serviceId, key, url, fnStr, fnParamsStr }, - tabId - ); + let value; + if (Shared.manifestVersion === 3) { + const results = await browser.scripting.executeScript({ + target: { tabId }, + func: Shared.functionsToInject[id], + args: [params], + // @ts-expect-error This is a newer value, so it's missing from the types. + world: 'MAIN', + }); + value = results[0].result as T | null; + } else { + value = (await Messaging.toContent( + { action: 'inject-function', serviceId, key, url, params }, + tabId + )) as T | null; + } void browser.tabs.remove(tabId); - resolve(value as T | null); + resolve(value); Shared.events.unsubscribe('CONTENT_SCRIPT_CONNECT', null, onScriptConnect); }; @@ -197,7 +263,7 @@ class _ScriptInjector { Shared.events.subscribe('CONTENT_SCRIPT_CONNECT', null, onScriptConnect); Tabs.open(url, { active: false }) - .then((tab) => (tabId = tab?.id)) + .then((tab) => (tabId = tab?.id ?? null)) .catch(() => { // Do nothing }); diff --git a/src/common/ScrobbleParser.ts b/src/common/ScrobbleParser.ts index dba75622..8b810328 100644 --- a/src/common/ScrobbleParser.ts +++ b/src/common/ScrobbleParser.ts @@ -1,5 +1,6 @@ import { ServiceApi } from '@apis/ServiceApi'; import { ScriptInjector } from '@common/ScriptInjector'; +import { Shared } from '@common/Shared'; import { createScrobbleItem, ScrobbleItem, ScrobbleItemValues } from '@models/Item'; export interface ScrobbleParserOptions { @@ -82,7 +83,7 @@ export abstract class ScrobbleParser { * Below are the methods that can be used to parse the playback. Generic methods do not need to be overridden in the child class, as they should work out-of-the-box. If one method fails, the next one is attempted, in the order listed. * * 1. **video player:** generic method, based on `videoPlayerSelector`, which can be specified through the options - * 2. **injected script:** specific method (requires implementation of `playbackFnToInject`) + * 2. **injected script:** specific method (requires adding a function to the `{serviceId}-playback` key in `Shared.functionsToInject` at the `{serviceName}Api.ts` file e.g. `netflix-playback` at `NetflixApi.ts`) * 3. **DOM:** specific method (requires override) * 4. **custom:** specific method (requires override) */ @@ -169,20 +170,17 @@ export abstract class ScrobbleParser { } protected async parsePlaybackFromInjectedScript(): Promise | null> { - if (this.playbackFnToInject) { + if (`${this.api.id}-playback` in Shared.functionsToInject) { const playback = await ScriptInjector.inject>( this.api.id, 'playback', - '', - this.playbackFnToInject + '' ); return playback; } return null; } - protected playbackFnToInject: (() => Partial | null) | null = null; - protected parsePlaybackFromDom(): Promisable | null> { return null; } @@ -195,7 +193,7 @@ export abstract class ScrobbleParser { * Below are the methods that can be used to parse the item. Generic methods do not need to be overridden in the child class, as they should work out-of-the-box. If one method fails, the next one is attempted, in the order listed. * * 1. **API:** generic method, but requires a non-null return from `parseItemId` and the implementation of `*Api#getItem` - * 2. **injected script:** specific method (requires implementation of `itemFnToInject`) + * 2. **injected script:** specific method (requires adding a function to the `{serviceId}-item` key in `Shared.functionsToInject` at the `{serviceName}Api.ts` file e.g. `netflix-item` at `NetflixApi.ts`) * 3. **DOM:** specific method (requires override) * 4. **custom:** specific method (requires override) */ @@ -235,13 +233,8 @@ export abstract class ScrobbleParser { } protected async parseItemFromInjectedScript(): Promise { - if (this.itemFnToInject) { - const savedItem = await ScriptInjector.inject( - this.api.id, - 'item', - '', - this.itemFnToInject - ); + if (`${this.api.id}-item` in Shared.functionsToInject) { + const savedItem = await ScriptInjector.inject(this.api.id, 'item', ''); if (savedItem) { return createScrobbleItem(savedItem); } @@ -249,8 +242,6 @@ export abstract class ScrobbleParser { return null; } - protected itemFnToInject: (() => ScrobbleItemValues | null) | null = null; - protected parseItemFromDom(): Promisable { return null; } @@ -263,7 +254,7 @@ export abstract class ScrobbleParser { * Below are the methods that can be used to parse the item ID. Generic methods do not need to be overridden in the child class, as they should work out-of-the-box. If one method fails, the next one is attempted, in the order listed. * * 1. **URL:** generic method, based on `watchingUrlRegex`, which can be specified through the options - * 2. **injected script:** specific method (requires implementation of `itemIdFnToInject`) + * 2. **injected script:** specific method (requires adding a function to the `{serviceId}-item-id` key in `Shared.functionsToInject` at the `{serviceName}Api.ts` file e.g. `netflix-item-id` at `NetflixApi.ts`) * 3. **DOM:** specific method (requires override) * 4. **custom:** specific method (requires override) */ @@ -301,20 +292,13 @@ export abstract class ScrobbleParser { } protected async parseItemIdFromInjectedScript(): Promise { - if (this.itemIdFnToInject) { - const id = await ScriptInjector.inject( - this.api.id, - 'item-id', - '', - this.itemIdFnToInject - ); + if (`${this.api.id}-item-id` in Shared.functionsToInject) { + const id = await ScriptInjector.inject(this.api.id, 'item-id', ''); return id; } return null; } - protected itemIdFnToInject: (() => string | null) | null = null; - protected parseItemIdFromDom(): Promisable { return null; } diff --git a/src/common/SessionStorage.ts b/src/common/SessionStorage.ts new file mode 100644 index 00000000..84f22f77 --- /dev/null +++ b/src/common/SessionStorage.ts @@ -0,0 +1,26 @@ +import browser, { Storage as WebExtStorage } from 'webextension-polyfill'; +import { Shared } from '@common/Shared'; + +export interface SessionStorageValues { + injectedContentScriptTabs?: number[]; +} + +class _SessionStorage { + instance = + Shared.manifestVersion === 3 + ? // @ts-expect-error `session` is a newer key, so it's missing from the types. + (browser.storage.session as WebExtStorage.LocalStorageArea) + : browser.storage.local; + + async set(values: SessionStorageValues): Promise { + return this.instance.set(values); + } + + async get( + keys?: keyof SessionStorageValues | (keyof SessionStorageValues)[] | null + ): Promise { + return this.instance.get(keys); + } +} + +export const SessionStorage = new _SessionStorage(); diff --git a/src/common/Shared.ts b/src/common/Shared.ts index b756074a..3f282eb7 100644 --- a/src/common/Shared.ts +++ b/src/common/Shared.ts @@ -12,6 +12,7 @@ export interface SharedValues { rollbarToken: string; tmdbApiKey: string; + manifestVersion: number; browser: BrowserName; pageType: PageType; tabId: number | null; @@ -22,6 +23,8 @@ export interface SharedValues { errors: typeof Errors; events: typeof EventDispatcher; + functionsToInject: Record unknown>; + waitForInit: () => Promise; finishInit: () => void; } @@ -56,6 +59,7 @@ export const Shared: SharedValues = { rollbarToken: process.env.ROLLBAR_TOKEN || '', tmdbApiKey: process.env.TMDB_API_KEY || '', + manifestVersion: browser.runtime.getManifest().manifest_version, browser: browsers[browserPrefix] || 'unknown', pageType: 'content', tabId: null, @@ -65,6 +69,8 @@ export const Shared: SharedValues = { errors: {} as typeof Errors, events: {} as typeof EventDispatcher, + functionsToInject: {}, + waitForInit: () => initPromise, finishInit: () => initPromiseResolve(null), }; diff --git a/src/global.d.ts b/src/global.d.ts index 32c436eb..86391e1e 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -10,6 +10,8 @@ declare let cloneInto: (value: T, window: Window) => T; declare type PromiseResolve = (value: T | PromiseLike) => void; +declare type PromiseReject = (value: Error) => void; + declare type Messages = typeof import('@locales/en/messages.json'); declare type MessageName = keyof Messages; diff --git a/src/modules/background/background.ts b/src/modules/background/background.ts index 14e8e9a8..4e0d7ae4 100644 --- a/src/modules/background/background.ts +++ b/src/modules/background/background.ts @@ -19,11 +19,13 @@ import '@images/uts-icon-38.png'; import '@images/uts-icon-selected-19.png'; import '@images/uts-icon-selected-38.png'; +Shared.pageType = 'background'; + Cache.addBackgroundListeners(); AutoSync.addBackgroundListeners(); +Messaging.addListeners(); const init = async () => { - Shared.pageType = 'background'; await BrowserStorage.init(); BrowserAction.init(); Errors.init(); @@ -38,6 +40,8 @@ const init = async () => { }; Messaging.addHandlers({ + 'connect-content-script': (message, tabId) => Messaging.connectContentScript(message, tabId), + 'open-tab': (message) => Tabs.open(message.url, message.extraProperties), 'get-tab-id': (message, tabId) => tabId, @@ -65,6 +69,9 @@ Messaging.addHandlers({ 'show-notification': (message) => Notifications.show(message.title, message.message), 'send-to-all-content': (message) => Messaging.toAllContent(message.message), + + 'inject-function-from-background': ({ serviceId, key, url, params, tabId }) => + ScriptInjector.injectInTab(serviceId, key, url, params, tabId), }); void init(); diff --git a/src/modules/content/service/service.ts b/src/modules/content/service/service.ts index e7ce8ad8..ed6033bd 100644 --- a/src/modules/content/service/service.ts +++ b/src/modules/content/service/service.ts @@ -8,8 +8,11 @@ import { getScrobbleController } from '@common/ScrobbleController'; import { getScrobbleEvents } from '@common/ScrobbleEvents'; import { Shared } from '@common/Shared'; +Shared.pageType = 'content'; + +Messaging.addListeners(); + export const init = async (serviceId: string): Promise => { - Shared.pageType = 'content'; await BrowserStorage.init(); Errors.init(); EventDispatcher.init(); @@ -21,6 +24,6 @@ export const init = async (serviceId: string): Promise => { }; Messaging.addHandlers({ - 'inject-function': ({ serviceId, key, url, fnStr, fnParamsStr }) => - ScriptInjector.inject(serviceId, key, url, fnStr, fnParamsStr), + 'inject-function': ({ serviceId, key, url, params }) => + ScriptInjector.inject(serviceId, key, url, params), }); diff --git a/src/modules/history/history.tsx b/src/modules/history/history.tsx index 5e31e9ec..7b643136 100644 --- a/src/modules/history/history.tsx +++ b/src/modules/history/history.tsx @@ -9,8 +9,11 @@ import { HistoryApp } from '@history/HistoryApp'; import { GlobalStyles } from '@mui/material'; import ReactDOM from 'react-dom'; +Shared.pageType = 'popup'; + +Messaging.addListeners(); + const init = async () => { - Shared.pageType = 'popup'; await BrowserStorage.init(); Errors.init(); EventDispatcher.init(); diff --git a/src/modules/options/options.tsx b/src/modules/options/options.tsx index 78c2cd7c..c76dbf93 100644 --- a/src/modules/options/options.tsx +++ b/src/modules/options/options.tsx @@ -7,8 +7,11 @@ import { AppWrapper } from '@components/AppWrapper'; import { OptionsApp } from '@options/OptionsApp'; import ReactDOM from 'react-dom'; +Shared.pageType = 'popup'; + +Messaging.addListeners(); + const init = async () => { - Shared.pageType = 'popup'; await BrowserStorage.init(); Errors.init(); EventDispatcher.init(); diff --git a/src/modules/popup/components/PopupWatching.tsx b/src/modules/popup/components/PopupWatching.tsx index a89aad93..659e8bb9 100644 --- a/src/modules/popup/components/PopupWatching.tsx +++ b/src/modules/popup/components/PopupWatching.tsx @@ -25,7 +25,7 @@ export const PopupWatching = ({ item, isPaused }: PopupWatchingProps): JSX.Eleme return ( <> - + { - Shared.pageType = 'popup'; await BrowserStorage.init(); Errors.init(); EventDispatcher.init(); diff --git a/src/services/amazon-prime/AmazonPrimeApi.ts b/src/services/amazon-prime/AmazonPrimeApi.ts index 4a82f329..860fd5fa 100644 --- a/src/services/amazon-prime/AmazonPrimeApi.ts +++ b/src/services/amazon-prime/AmazonPrimeApi.ts @@ -223,7 +223,7 @@ class _AmazonPrimeApi extends ServiceApi { return !!this.session && !!this.session.profileName; } - async loadHistoryItems() { + async loadHistoryItems(cancelKey = 'default') { if (!this.isActivated) { await this.activate(); } @@ -235,6 +235,7 @@ class _AmazonPrimeApi extends ServiceApi { args: this.nextToken ? `%22nextToken%22%3A%22${this.nextToken}%22` : '', }), method: 'GET', + cancelKey, }); const historyResponse = JSON.parse(historyResponseText) as AmazonPrimeHistoryResponse; diff --git a/src/services/amc-plus/AmcPlusApi.ts b/src/services/amc-plus/AmcPlusApi.ts index 1b8c3173..f43d4a8b 100644 --- a/src/services/amc-plus/AmcPlusApi.ts +++ b/src/services/amc-plus/AmcPlusApi.ts @@ -163,24 +163,25 @@ class _AmcPlusApi extends ServiceApi { const result = await ScriptInjector.inject>( this.id, 'session', - this.HOST_URL, - () => { - const session: Partial = {}; - - const accessToken = window.localStorage.getItem('access_token') ?? ''; - const cacheHash = window.localStorage.getItem('cache_hash') ?? ''; - const userCacheHash = window.localStorage.getItem('user_cache_hash') ?? ''; - session.auth = { - accessToken, - cacheHash, - userCacheHash, - }; - - return session; - } + this.HOST_URL ); return result; } } +Shared.functionsToInject[`${AmcPlusService.id}-session`] = () => { + const session: Partial = {}; + + const accessToken = window.localStorage.getItem('access_token') ?? ''; + const cacheHash = window.localStorage.getItem('cache_hash') ?? ''; + const userCacheHash = window.localStorage.getItem('user_cache_hash') ?? ''; + session.auth = { + accessToken, + cacheHash, + userCacheHash, + }; + + return session; +}; + export const AmcPlusApi = new _AmcPlusApi(); diff --git a/src/services/crunchyroll/CrunchyrollApi.ts b/src/services/crunchyroll/CrunchyrollApi.ts index 2bb9fe4e..3386a763 100644 --- a/src/services/crunchyroll/CrunchyrollApi.ts +++ b/src/services/crunchyroll/CrunchyrollApi.ts @@ -107,7 +107,7 @@ class _CrunchyrollApi extends ServiceApi { return !!this.session && this.session.profileName !== null; } - async loadHistoryItems(): Promise { + async loadHistoryItems(cancelKey = 'default'): Promise { // We do this here because the token will expire within minutes. await this.checkLogin(); @@ -119,6 +119,7 @@ class _CrunchyrollApi extends ServiceApi { const responseText = await this.authRequests.send({ url: this.nextHistoryUrl, method: 'GET', + cancelKey, }); const page = this.parseJsonWithDates(responseText, [ 'date_played', diff --git a/src/services/hbo-max/HboMaxApi.ts b/src/services/hbo-max/HboMaxApi.ts index f8f9b9a9..dd91447b 100644 --- a/src/services/hbo-max/HboMaxApi.ts +++ b/src/services/hbo-max/HboMaxApi.ts @@ -293,7 +293,7 @@ class _HboMaxApi extends ServiceApi { return !!this.session && !!this.session.profileName; } - async loadHistoryItems(): Promise { + async loadHistoryItems(cancelKey = 'default'): Promise { if (!this.isActivated) { await this.activate(); } @@ -306,6 +306,7 @@ class _HboMaxApi extends ServiceApi { const historyResponseText = await this.authRequests.send({ url: Utils.replace(this.HISTORY_URL, this.session), method: 'GET', + cancelKey, }); const historyResponse = JSON.parse(historyResponseText) as HboMaxHistoryResponse; const historyResponseItems = historyResponse.filter( @@ -431,27 +432,7 @@ class _HboMaxApi extends ServiceApi { const result = await ScriptInjector.inject>( this.id, 'session', - this.HOST_URL, - () => { - const session: Partial = {}; - - const authStr = window.localStorage.getItem('authToken'); - if (authStr) { - const authObj = JSON.parse(authStr) as HboMaxAuthObj; - session.auth = { - accessToken: authObj.access_token, - refreshToken: authObj.refresh_token, - expiresAt: authObj.expires_on, - }; - } - - const deviceSerialNumber = window.localStorage.getItem('deviceSerialNumber'); - if (deviceSerialNumber) { - session.deviceSerialNumber = deviceSerialNumber; - } - - return session; - } + this.HOST_URL ); if (result?.auth) { result.auth.expiresAt = Utils.unix(result.auth.expiresAt); @@ -460,4 +441,25 @@ class _HboMaxApi extends ServiceApi { } } +Shared.functionsToInject[`${HboMaxService.id}-session`] = () => { + const session: Partial = {}; + + const authStr = window.localStorage.getItem('authToken'); + if (authStr) { + const authObj = JSON.parse(authStr) as HboMaxAuthObj; + session.auth = { + accessToken: authObj.access_token, + refreshToken: authObj.refresh_token, + expiresAt: authObj.expires_on, + }; + } + + const deviceSerialNumber = window.localStorage.getItem('deviceSerialNumber'); + if (deviceSerialNumber) { + session.deviceSerialNumber = deviceSerialNumber; + } + + return session; +}; + export const HboMaxApi = new _HboMaxApi(); diff --git a/src/services/mubi/MubiApi.ts b/src/services/mubi/MubiApi.ts index 6d60c37b..eca782b5 100644 --- a/src/services/mubi/MubiApi.ts +++ b/src/services/mubi/MubiApi.ts @@ -76,7 +76,7 @@ class _MubiApi extends ServiceApi { return !!this.session; } - async loadHistoryItems(): Promise { + async loadHistoryItems(cancelKey = 'default'): Promise { if (!this.session) { await this.activate(); } @@ -87,6 +87,7 @@ class _MubiApi extends ServiceApi { const responseText = await this.sendRequest({ url: `${this.API_URL}/view_logs?page=${this.nextHistoryPage + 1}`, method: 'GET', + cancelKey, }); const responseJson = JSON.parse(responseText) as MubiHistoryResponse; historyItems = responseJson?.view_logs ?? []; diff --git a/src/services/netflix/NetflixApi.ts b/src/services/netflix/NetflixApi.ts index f69b67ec..9cc59c79 100644 --- a/src/services/netflix/NetflixApi.ts +++ b/src/services/netflix/NetflixApi.ts @@ -206,7 +206,7 @@ class _NetflixApi extends ServiceApi { return this.session?.profileName != null; } - async loadHistoryItems(): Promise { + async loadHistoryItems(cancelKey = 'default'): Promise { if (!this.isActivated) { await this.activate(); } @@ -216,6 +216,7 @@ class _NetflixApi extends ServiceApi { const responseText = await Requests.send({ url: `${this.API_URL}/mre/viewingactivity?languages=en-US&authURL=${this.session.authUrl}&pg=${this.nextHistoryPage}`, method: 'GET', + cancelKey, }); const responseJson = JSON.parse(responseText) as NetflixHistoryResponse; const responseItems = responseJson?.viewedItems ?? []; @@ -444,19 +445,7 @@ class _NetflixApi extends ServiceApi { } getSession(): Promise { - return ScriptInjector.inject(this.id, 'session', '', () => { - let session: NetflixSession | null = null; - const { netflix } = window; - if (netflix) { - const { userInfo } = netflix.reactContext.models; - const authUrl = userInfo.data.authURL; - const profileName = userInfo.data.name; - if (authUrl) { - session = { authUrl, profileName }; - } - } - return session; - }); + return ScriptInjector.inject(this.id, 'session', ''); } extractSession(text: string): NetflixSession | null { @@ -472,4 +461,18 @@ class _NetflixApi extends ServiceApi { } } +Shared.functionsToInject[`${NetflixService.id}-session`] = () => { + let session: NetflixSession | null = null; + const { netflix } = window; + if (netflix) { + const { userInfo } = netflix.reactContext.models; + const authUrl = userInfo.data.authURL; + const profileName = userInfo.data.name; + if (authUrl) { + session = { authUrl, profileName }; + } + } + return session; +}; + export const NetflixApi = new _NetflixApi(); diff --git a/src/services/nrk/NrkApi.ts b/src/services/nrk/NrkApi.ts index b5714b30..a6f48db0 100644 --- a/src/services/nrk/NrkApi.ts +++ b/src/services/nrk/NrkApi.ts @@ -1,6 +1,7 @@ import { NrkService } from '@/nrk/NrkService'; import { ServiceApi } from '@apis/ServiceApi'; import { Requests, withHeaders } from '@common/Requests'; +import { Shared } from '@common/Shared'; import { Utils } from '@common/Utils'; import { BaseItemValues, @@ -179,13 +180,14 @@ class _NrkApi extends ServiceApi { return !!this.session && this.session.profileName !== null; } - async loadHistoryItems(): Promise { + async loadHistoryItems(cancelKey = 'default'): Promise { if (!this.isActivated) { await this.activate(); } const responseText = await this.authRequests.send({ url: this.nextHistoryUrl, method: 'GET', + cancelKey, }); const responseJson = JSON.parse(responseText) as NrkProgressResponse; const responseItems = responseJson.progresses; @@ -333,4 +335,16 @@ class _NrkApi extends ServiceApi { } } +Shared.functionsToInject[`${NrkService.id}-item-id`] = () => { + let itemId: string | null = null; + const { player } = window; + if (player) { + const playbackSession = player.getPlaybackSession(); + if (playbackSession) { + itemId = playbackSession.mediaItem?.id ?? null; + } + } + return itemId; +}; + export const NrkApi = new _NrkApi(); diff --git a/src/services/nrk/NrkParser.ts b/src/services/nrk/NrkParser.ts index edbf4f75..dd9ba7ca 100644 --- a/src/services/nrk/NrkParser.ts +++ b/src/services/nrk/NrkParser.ts @@ -5,17 +5,6 @@ class _NrkParser extends ScrobbleParser { constructor() { super(NrkApi, { videoPlayerSelector: '.tv-series-video-player video' }); } - - itemIdFnToInject = () => { - let itemId: string | null = null; - const { player } = window; - if (player) { - const playbackSession = player.getPlaybackSession(); - if (playbackSession) { - itemId = playbackSession.mediaItem?.id ?? null; - } - } - return itemId; - }; } + export const NrkParser = new _NrkParser(); diff --git a/src/services/viaplay/ViaplayApi.ts b/src/services/viaplay/ViaplayApi.ts index 877671a9..12333903 100644 --- a/src/services/viaplay/ViaplayApi.ts +++ b/src/services/viaplay/ViaplayApi.ts @@ -153,13 +153,14 @@ class _ViaplayApi extends ServiceApi { return !!this.session && this.session.profileName !== null; } - async loadHistoryItems(): Promise { + async loadHistoryItems(cancelKey = 'default'): Promise { if (!this.isActivated) { await this.activate(); } const responseText = await Requests.send({ url: this.nextHistoryUrl, method: 'GET', + cancelKey, }); let historyPage: ViaplayHistoryPage; if (this.nextHistoryUrl === this.HISTORY_API_URL) { diff --git a/webpack.config.ts b/webpack.config.ts index 669043f6..61c6c67c 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -219,8 +219,7 @@ const getWebpackConfig = (env: Environment): webpack.Configuration => { }; const getManifest = (browserName: string): string => { - const manifest: WebExtManifest.WebExtensionManifest & { key?: string } = { - manifest_version: 2, + const manifest: Partial & { key?: string } = { name: 'Universal Trakt Scrobbler', version: packageJson.version, description: '__MSG_appDescription__', @@ -228,58 +227,84 @@ const getManifest = (browserName: string): string => { 16: 'images/uts-icon-16.png', 128: 'images/uts-icon-128.png', }, - background: { - scripts: ['background.js'], - persistent: true, - }, content_scripts: [ { js: ['trakt.js'], matches: ['*://*.trakt.tv/apps*'], - run_at: 'document_start', }, ], default_locale: 'en', - optional_permissions: [ - 'cookies', - 'notifications', - 'tabs', - 'webRequest', - 'webRequestBlocking', - '*://api.rollbar.com/*', - ...Object.values(services) - .map((service) => service.hostPatterns) - .flat(), - ], - browser_action: { - default_icon: { - 19: 'images/uts-icon-19.png', - 38: 'images/uts-icon-38.png', - }, - default_popup: 'popup.html', - default_title: 'Universal Trakt Scrobbler', - }, - permissions: [ - 'alarms', - 'identity', - 'storage', - 'unlimitedStorage', - '*://*.trakt.tv/*', - '*://*.themoviedb.org/*', - '*://*.uts.rafaelgomes.xyz/*', - ], - web_accessible_resources: ['images/*'], - // Uncomment this to connect to react-devtools - // content_security_policy: "script-src 'self' http://localhost:8097; object-src 'self'", }; switch (browserName) { case 'chrome': { + manifest.manifest_version = 3; + manifest.background = { + service_worker: 'background.js', + }; + manifest.optional_permissions = ['notifications', 'tabs']; + // @ts-expect-error This is a newer key, so it's missing from the types. + manifest.optional_host_permissions = [ + '*://api.rollbar.com/*', + ...Object.values(services) + .map((service) => service.hostPatterns) + .flat(), + ]; + manifest.permissions = ['alarms', 'identity', 'scripting', 'storage', 'unlimitedStorage']; + manifest.host_permissions = [ + '*://*.trakt.tv/*', + '*://*.themoviedb.org/*', + '*://*.uts.rafaelgomes.xyz/*', + ]; + manifest.action = { + default_icon: { + 19: 'images/uts-icon-19.png', + 38: 'images/uts-icon-38.png', + }, + default_popup: 'popup.html', + default_title: 'Universal Trakt Scrobbler', + }; if (process.env.CHROME_EXTENSION_KEY) { manifest.key = process.env.CHROME_EXTENSION_KEY; } break; } case 'firefox': { + manifest.manifest_version = 2; + manifest.background = { + scripts: ['background.js'], + persistent: false, + }; + manifest.optional_permissions = [ + 'cookies', + 'notifications', + 'tabs', + 'webRequest', + 'webRequestBlocking', + '*://api.rollbar.com/*', + ...Object.values(services) + .map((service) => service.hostPatterns) + .flat(), + ]; + manifest.permissions = [ + 'alarms', + 'identity', + 'storage', + 'unlimitedStorage', + '*://*.trakt.tv/*', + '*://*.themoviedb.org/*', + '*://*.uts.rafaelgomes.xyz/*', + ]; + manifest.browser_action = { + default_icon: { + 19: 'images/uts-icon-19.png', + 38: 'images/uts-icon-38.png', + }, + default_popup: 'popup.html', + default_title: 'Universal Trakt Scrobbler', + }; + // Uncomment this to connect to react-devtools + // manifest.content_security_policy = + // "script-src 'self' http://localhost:8097; object-src 'self'"; if (process.env.FIREFOX_EXTENSION_ID) { manifest.browser_specific_settings = { gecko: {