diff --git a/package.json b/package.json index 91f2a465..627c8573 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "react-router-dom": "^5.2.0", "rollbar": "^2.19.3", "typeface-roboto": "^0.0.75", - "webextension-polyfill": "^0.6.0" + "webextension-polyfill": "^0.8.0" }, "devDependencies": { "@babel/core": "^7.11.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a96a091..81ee70b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ dependencies: react-router-dom: 5.2.0_react@16.13.1 rollbar: 2.19.3 typeface-roboto: 0.0.75 - webextension-polyfill: 0.6.0 + webextension-polyfill: 0.8.0 devDependencies: '@babel/core': 7.11.4 '@babel/plugin-proposal-class-properties': 7.10.4_@babel+core@7.11.4 @@ -8935,10 +8935,10 @@ packages: dev: true resolution: integrity: sha512-oQZYDU3W8X867h8Jmt3129kRVKklz70db40Y6OzoTTuzOJpF/dB2KULJUf0txVPyUUXuyzV8GmT3nVvRHoG+Ew== - /webextension-polyfill/0.6.0: + /webextension-polyfill/0.8.0: dev: false resolution: - integrity: sha512-PlYwiX8e4bNZrEeBFxbFFsLtm0SMPxJliLTGdNCA0Bq2XkWrAn2ejUd+89vZm+8BnfFB1BclJyCz3iKsm2atNg== + integrity: sha512-a19+DzlT6Kp9/UI+mF9XQopeZ+n2ussjhxHJ4/pmIGge9ijCDz7Gn93mNnjpZAk95T4Tae8iHZ6sSf869txqiQ== /webpack-cli/3.3.12_webpack@4.44.1: dependencies: chalk: 2.4.2 @@ -9246,6 +9246,6 @@ specifiers: typeface-roboto: ^0.0.75 typescript: ^4.0.2 web-ext-types: ^3.2.1 - webextension-polyfill: ^0.6.0 + webextension-polyfill: ^0.8.0 webpack: ^4.44.1 webpack-cli: ^3.3.12 diff --git a/src/modules/background/background.ts b/src/modules/background/background.ts index 063944f9..b7571b28 100644 --- a/src/modules/background/background.ts +++ b/src/modules/background/background.ts @@ -107,6 +107,15 @@ export interface SaveCorrectionSuggestionMessage { url: string; } +export interface NavigationCommittedParams { + transitionType: browser.webNavigation.TransitionType; + tabId: number; + url: string; +} + +const injectedTabs = new Set(); +let streamingServiceScripts: browser.runtime.Manifest['content_scripts'] | null = null; + const init = async () => { Shared.pageType = 'background'; await BrowserStorage.sync(); @@ -116,16 +125,45 @@ const init = async () => { } browser.tabs.onRemoved.addListener((tabId) => void onTabRemoved(tabId)); browser.storage.onChanged.addListener(onStorageChanged); + if (storage.options?.streamingServices) { + const scrobblerEnabled = (Object.entries(storage.options.streamingServices) as [ + StreamingServiceId, + boolean + ][]).some( + ([streamingServiceId, value]) => value && streamingServices[streamingServiceId].hasScrobbler + ); + if (scrobblerEnabled) { + addWebNavigationListener(storage.options); + } + } if (storage.options?.grantCookies) { addWebRequestListener(); } browser.runtime.onMessage.addListener((onMessage as unknown) as browser.runtime.onMessageEvent); }; +const onTabUpdated = (_: unknown, __: unknown, tab: browser.tabs.Tab) => { + void injectScript(tab); +}; + /** * Checks if the tab that was closed was the tab that was scrobbling and, if that's the case, stops the scrobble. */ const onTabRemoved = async (tabId: number) => { + try { + /** + * Some single-page apps trigger the onTabRemoved event when navigating through pages, + * so we double check here to make sure that the tab was actually removed. + * If the tab was removed, this will throw an error. + */ + await browser.tabs.get(tabId); + return; + } catch (err) { + // Do nothing + } + if (injectedTabs.has(tabId)) { + injectedTabs.delete(tabId); + } const { scrobblingTabId } = await BrowserStorage.get('scrobblingTabId'); if (tabId !== scrobblingTabId) { return; @@ -139,6 +177,32 @@ const onTabRemoved = async (tabId: number) => { await BrowserAction.setInactiveIcon(); }; +const injectScript = async (tab: Partial, reload = false) => { + if ( + !streamingServiceScripts || + tab.status !== 'complete' || + !tab.id || + !tab.url || + !tab.url.startsWith('http') || + (injectedTabs.has(tab.id) && !reload) + ) { + return; + } + for (const { matches, js, run_at: runAt } of streamingServiceScripts) { + if (!js || !runAt) { + continue; + } + const isMatch = matches.find((match) => tab.url?.match(match)); + if (isMatch) { + injectedTabs.add(tab.id); + for (const file of js) { + await browser.tabs.executeScript(tab.id, { file, runAt }); + } + break; + } + } +}; + const onStorageChanged = ( changes: browser.storage.ChangeDict, areaName: browser.storage.StorageName @@ -149,13 +213,79 @@ const onStorageChanged = ( if (!changes.options) { return; } - if ((changes.options.newValue as StorageValuesOptions)?.grantCookies) { + const newValue = changes.options.newValue as StorageValuesOptions | undefined; + if (!newValue) { + return; + } + if (newValue.streamingServices) { + const scrobblerEnabled = (Object.entries(newValue.streamingServices) as [ + StreamingServiceId, + boolean + ][]).some( + ([streamingServiceId, value]) => value && streamingServices[streamingServiceId].hasScrobbler + ); + if (scrobblerEnabled) { + addWebNavigationListener(newValue); + } else { + removeWebNavigationListener(); + } + } + if (newValue.grantCookies) { addWebRequestListener(); } else { removeWebRequestListener(); } }; +const addWebNavigationListener = (options: StorageValuesOptions) => { + streamingServiceScripts = Object.values(streamingServices) + .filter((service) => options.streamingServices[service.id] && service.hasScrobbler) + .map((service) => ({ + matches: service.hostPatterns.map((hostPattern) => + hostPattern.replace(/^\*:\/\/\*\./, 'https?://(www.)?').replace(/\/\*$/, '') + ), + js: ['js/lib/browser-polyfill.js', `js/${service.id}.js`], + run_at: 'document_idle', + })); + if (!browser.tabs.onUpdated.hasListener(onTabUpdated)) { + browser.tabs.onUpdated.addListener(onTabUpdated); + } + if ( + !browser.webNavigation || + browser.webNavigation.onCommitted.hasListener(onNavigationCommitted) + ) { + return; + } + browser.webNavigation.onCommitted.addListener(onNavigationCommitted); +}; + +const removeWebNavigationListener = () => { + if (browser.tabs.onUpdated.hasListener(onTabUpdated)) { + browser.tabs.onUpdated.removeListener(onTabUpdated); + } + if ( + !browser.webNavigation || + !browser.webNavigation.onCommitted.hasListener(onNavigationCommitted) + ) { + return; + } + browser.webNavigation.onCommitted.removeListener(onNavigationCommitted); +}; + +const onNavigationCommitted = ({ transitionType, tabId, url }: NavigationCommittedParams) => { + if (transitionType !== 'reload') { + return; + } + void injectScript( + { + status: 'complete', + id: tabId, + url: url, + }, + true + ); +}; + const addWebRequestListener = () => { if ( !browser.webRequest || diff --git a/src/modules/options/OptionsApp.tsx b/src/modules/options/OptionsApp.tsx index 1d11e7d0..5d1f548b 100644 --- a/src/modules/options/OptionsApp.tsx +++ b/src/modules/options/OptionsApp.tsx @@ -129,11 +129,17 @@ export const OptionsApp: React.FC = () => { for (const option of Object.values(options) as Option[]) { addOptionToSave(optionsToSave, option); } + const scrobblerEnabled = (Object.entries(optionsToSave.streamingServices) as [ + StreamingServiceId, + boolean + ][]).some( + ([streamingServiceId, value]) => value && streamingServices[streamingServiceId].hasScrobbler + ); const permissionPromises: Promise[] = []; if (originsToAdd.length > 0) { permissionPromises.push( browser.permissions.request({ - permissions: [], + permissions: scrobblerEnabled ? ['webNavigation'] : [], origins: originsToAdd, }) ); @@ -141,7 +147,7 @@ export const OptionsApp: React.FC = () => { if (originsToRemove.length > 0) { permissionPromises.push( browser.permissions.remove({ - permissions: [], + permissions: scrobblerEnabled ? [] : ['webNavigation'], origins: originsToRemove, }) ); diff --git a/webpack.config.ts b/webpack.config.ts index 5a2c7bc2..58f3a8da 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -177,13 +177,6 @@ const getWebpackConfig = (env: Environment) => { }; const getManifest = (config: Config, browserName: string): string => { - const streamingServiceScripts: Manifest['content_scripts'] = Object.values(streamingServices) - .filter((service) => service.hasScrobbler) - .map((service) => ({ - js: ['js/lib/browser-polyfill.js', `js/${service.id}.js`], - matches: service.hostPatterns, - run_at: 'document_idle', - })); const manifest: Manifest = { manifest_version: 2, name: 'Universal Trakt Scrobbler', @@ -203,12 +196,12 @@ const getManifest = (config: Config, browserName: string): string => { matches: ['*://*.trakt.tv/apps*'], run_at: 'document_start', }, - ...streamingServiceScripts, ], default_locale: 'en', optional_permissions: [ 'cookies', 'notifications', + 'webNavigation', 'webRequest', 'webRequestBlocking', '*://api.rollbar.com/*',