From 537c74e16a394df16a4b368caa09ea5755f78dfb Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Thu, 29 Aug 2024 11:28:35 +0100 Subject: [PATCH] feat[react-devtools]: support Manifest v3 for Firefox extension (#30824) Firefox [finally supports `ExecutionWorld.MAIN`](https://bugzilla.mozilla.org/show_bug.cgi?id=1736575) in content scripts, which means we can migrate the browser extension to Manifest V3. This PR also removes a bunch of no longer required explicit branching for Firefox case, when we are using Manifest V3-only APIs. We are also removing XMLHttpRequest injection, which is no longer needed and restricted in Manifest V3. The new standardized approach (same as in Chromium) doesn't violate CSP rules, which means that extension can finally be used for apps running in production mode. --- .../firefox/manifest.json | 40 +++++---- .../dynamicallyInjectContentScripts.js | 90 +++++++------------ .../src/background/executeScript.js | 39 -------- .../background/setExtensionIconAndPopup.js | 6 +- .../src/background/tabsManager.js | 24 ++--- .../src/contentScripts/prepareInjection.js | 40 --------- .../react-devtools-shared/babel.config.js | 2 +- 7 files changed, 66 insertions(+), 175 deletions(-) diff --git a/packages/react-devtools-extensions/firefox/manifest.json b/packages/react-devtools-extensions/firefox/manifest.json index ffa48634e0..3c2d417d58 100644 --- a/packages/react-devtools-extensions/firefox/manifest.json +++ b/packages/react-devtools-extensions/firefox/manifest.json @@ -1,12 +1,12 @@ { - "manifest_version": 2, + "manifest_version": 3, "name": "React Developer Tools", "description": "Adds React debugging tools to the Firefox Developer Tools.", "version": "5.3.1", - "applications": { + "browser_specific_settings": { "gecko": { "id": "@react-devtools", - "strict_min_version": "102.0" + "strict_min_version": "128.0" } }, "icons": { @@ -15,22 +15,32 @@ "48": "icons/48-production.png", "128": "icons/128-production.png" }, - "browser_action": { + "action": { "default_icon": { "16": "icons/16-disabled.png", "32": "icons/32-disabled.png", "48": "icons/48-disabled.png", "128": "icons/128-disabled.png" }, - "default_popup": "popups/disabled.html", - "browser_style": true + "default_popup": "popups/disabled.html" }, "devtools_page": "main.html", - "content_security_policy": "script-src 'self' 'unsafe-eval' blob:; object-src 'self'", + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'" + }, "web_accessible_resources": [ - "main.html", - "panel.html", - "build/*.js" + { + "resources": [ + "main.html", + "panel.html", + "build/*.js", + "build/*.js.map" + ], + "matches": [ + "" + ], + "extension_ids": [] + } ], "background": { "scripts": [ @@ -38,12 +48,10 @@ ] }, "permissions": [ - "file:///*", - "http://*/*", - "https://*/*", - "clipboardWrite", - "scripting", - "devtools" + "scripting" + ], + "host_permissions": [ + "" ], "content_scripts": [ { diff --git a/packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js b/packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js index e19030457a..b1888b4e7c 100644 --- a/packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js +++ b/packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js @@ -1,58 +1,39 @@ /* global chrome */ -// Firefox doesn't support ExecutionWorld.MAIN yet -// equivalent logic for Firefox is in prepareInjection.js -const contentScriptsToInject = __IS_FIREFOX__ - ? [ - { - id: '@react-devtools/proxy', - js: ['build/proxy.js'], - matches: [''], - persistAcrossSessions: true, - runAt: 'document_end', - }, - { - id: '@react-devtools/file-fetcher', - js: ['build/fileFetcher.js'], - matches: [''], - persistAcrossSessions: true, - runAt: 'document_end', - }, - ] - : [ - { - id: '@react-devtools/proxy', - js: ['build/proxy.js'], - matches: [''], - persistAcrossSessions: true, - runAt: 'document_end', - world: chrome.scripting.ExecutionWorld.ISOLATED, - }, - { - id: '@react-devtools/file-fetcher', - js: ['build/fileFetcher.js'], - matches: [''], - persistAcrossSessions: true, - runAt: 'document_end', - world: chrome.scripting.ExecutionWorld.ISOLATED, - }, - { - id: '@react-devtools/hook', - js: ['build/installHook.js'], - matches: [''], - persistAcrossSessions: true, - runAt: 'document_start', - world: chrome.scripting.ExecutionWorld.MAIN, - }, - { - id: '@react-devtools/renderer', - js: ['build/renderer.js'], - matches: [''], - persistAcrossSessions: true, - runAt: 'document_start', - world: chrome.scripting.ExecutionWorld.MAIN, - }, - ]; +const contentScriptsToInject = [ + { + id: '@react-devtools/proxy', + js: ['build/proxy.js'], + matches: [''], + persistAcrossSessions: true, + runAt: 'document_end', + world: chrome.scripting.ExecutionWorld.ISOLATED, + }, + { + id: '@react-devtools/file-fetcher', + js: ['build/fileFetcher.js'], + matches: [''], + persistAcrossSessions: true, + runAt: 'document_end', + world: chrome.scripting.ExecutionWorld.ISOLATED, + }, + { + id: '@react-devtools/hook', + js: ['build/installHook.js'], + matches: [''], + persistAcrossSessions: true, + runAt: 'document_start', + world: chrome.scripting.ExecutionWorld.MAIN, + }, + { + id: '@react-devtools/renderer', + js: ['build/renderer.js'], + matches: [''], + persistAcrossSessions: true, + runAt: 'document_start', + world: chrome.scripting.ExecutionWorld.MAIN, + }, +]; async function dynamicallyInjectContentScripts() { try { @@ -61,9 +42,6 @@ async function dynamicallyInjectContentScripts() { // This fixes registering proxy content script in incognito mode await chrome.scripting.unregisterContentScripts(); - // equivalent logic for Firefox is in prepareInjection.js - // Manifest V3 method of injecting content script - // TODO(hoxyq): migrate Firefox to V3 manifests // Note: the "world" option in registerContentScripts is only available in Chrome v102+ // It's critical since it allows us to directly run scripts on the "main" world on the page // "document_start" allows it to run before the page's scripts diff --git a/packages/react-devtools-extensions/src/background/executeScript.js b/packages/react-devtools-extensions/src/background/executeScript.js index efe73229ec..8b80095d33 100644 --- a/packages/react-devtools-extensions/src/background/executeScript.js +++ b/packages/react-devtools-extensions/src/background/executeScript.js @@ -1,40 +1,5 @@ /* global chrome */ -// Firefox doesn't support ExecutionWorld.MAIN yet -// https://bugzilla.mozilla.org/show_bug.cgi?id=1736575 -function executeScriptForFirefoxInMainWorld({target, files}) { - return chrome.scripting.executeScript({ - target, - func: fileNames => { - function injectScriptSync(src) { - let code = ''; - const request = new XMLHttpRequest(); - request.addEventListener('load', function () { - code = this.responseText; - }); - request.open('GET', src, false); - request.send(); - - const script = document.createElement('script'); - script.textContent = code; - - // This script runs before the element is created, - // so we add the script to instead. - if (document.documentElement) { - document.documentElement.appendChild(script); - } - - if (script.parentNode) { - script.parentNode.removeChild(script); - } - } - - fileNames.forEach(file => injectScriptSync(chrome.runtime.getURL(file))); - }, - args: [files], - }); -} - export function executeScriptInIsolatedWorld({target, files}) { return chrome.scripting.executeScript({ target, @@ -44,10 +9,6 @@ export function executeScriptInIsolatedWorld({target, files}) { } export function executeScriptInMainWorld({target, files}) { - if (__IS_FIREFOX__) { - return executeScriptForFirefoxInMainWorld({target, files}); - } - return chrome.scripting.executeScript({ target, files, diff --git a/packages/react-devtools-extensions/src/background/setExtensionIconAndPopup.js b/packages/react-devtools-extensions/src/background/setExtensionIconAndPopup.js index 5c6e011114..51f233e284 100644 --- a/packages/react-devtools-extensions/src/background/setExtensionIconAndPopup.js +++ b/packages/react-devtools-extensions/src/background/setExtensionIconAndPopup.js @@ -3,9 +3,7 @@ 'use strict'; function setExtensionIconAndPopup(reactBuildType, tabId) { - const action = __IS_FIREFOX__ ? chrome.browserAction : chrome.action; - - action.setIcon({ + chrome.action.setIcon({ tabId, path: { '16': chrome.runtime.getURL(`icons/16-${reactBuildType}.png`), @@ -15,7 +13,7 @@ function setExtensionIconAndPopup(reactBuildType, tabId) { }, }); - action.setPopup({ + chrome.action.setPopup({ tabId, popup: chrome.runtime.getURL(`popups/${reactBuildType}.html`), }); diff --git a/packages/react-devtools-extensions/src/background/tabsManager.js b/packages/react-devtools-extensions/src/background/tabsManager.js index 23b566502a..192a6ce42c 100644 --- a/packages/react-devtools-extensions/src/background/tabsManager.js +++ b/packages/react-devtools-extensions/src/background/tabsManager.js @@ -18,26 +18,12 @@ function checkAndHandleRestrictedPageIfSo(tab) { // we can't update for any other types (prod,dev,outdated etc) // as the content script needs to be injected at document_start itself for those kinds of detection // TODO: Show a different popup page(to reload current page probably) for old tabs, opened before the extension is installed -if (__IS_CHROME__ || __IS_EDGE__) { - chrome.tabs.query({}, tabs => tabs.forEach(checkAndHandleRestrictedPageIfSo)); - chrome.tabs.onCreated.addListener((tabId, changeInfo, tab) => - checkAndHandleRestrictedPageIfSo(tab), - ); -} +chrome.tabs.query({}, tabs => tabs.forEach(checkAndHandleRestrictedPageIfSo)); +chrome.tabs.onCreated.addListener((tabId, changeInfo, tab) => + checkAndHandleRestrictedPageIfSo(tab), +); // Listen to URL changes on the active tab and update the DevTools icon. chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { - if (__IS_FIREFOX__) { - // We don't properly detect protected URLs in Firefox at the moment. - // However, we can reset the DevTools icon to its loading state when the URL changes. - // It will be updated to the correct icon by the onMessage callback below. - if (tab.active && changeInfo.status === 'loading') { - setExtensionIconAndPopup('disabled', tabId); - } - } else { - // Don't reset the icon to the loading state for Chrome or Edge. - // The onUpdated callback fires more frequently for these browsers, - // often after onMessage has been called. - checkAndHandleRestrictedPageIfSo(tab); - } + checkAndHandleRestrictedPageIfSo(tab); }); diff --git a/packages/react-devtools-extensions/src/contentScripts/prepareInjection.js b/packages/react-devtools-extensions/src/contentScripts/prepareInjection.js index d67ea7c405..1b9962a9a8 100644 --- a/packages/react-devtools-extensions/src/contentScripts/prepareInjection.js +++ b/packages/react-devtools-extensions/src/contentScripts/prepareInjection.js @@ -1,31 +1,5 @@ /* global chrome */ -import nullthrows from 'nullthrows'; - -// We run scripts on the page via the service worker (background/index.js) for -// Manifest V3 extensions (Chrome & Edge). -// We need to inject this code for Firefox only because it does not support ExecutionWorld.MAIN -// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld -// In this content script we have access to DOM, but don't have access to the webpage's window, -// so we inject this inline script tag into the webpage (allowed in Manifest V2). -function injectScriptSync(src) { - let code = ''; - const request = new XMLHttpRequest(); - request.addEventListener('load', function () { - code = this.responseText; - }); - request.open('GET', src, false); - request.send(); - - const script = document.createElement('script'); - script.textContent = code; - - // This script runs before the element is created, - // so we add the script to instead. - nullthrows(document.documentElement).appendChild(script); - nullthrows(script.parentNode).removeChild(script); -} - let lastSentDevToolsHookMessage; // We want to detect when a renderer attaches, and notify the "background page" @@ -60,17 +34,3 @@ window.addEventListener('pageshow', function ({target}) { chrome.runtime.sendMessage(lastSentDevToolsHookMessage); }); - -if (__IS_FIREFOX__) { - injectScriptSync(chrome.runtime.getURL('build/renderer.js')); - - // Inject a __REACT_DEVTOOLS_GLOBAL_HOOK__ global for React to interact with. - // Only do this for HTML documents though, to avoid e.g. breaking syntax highlighting for XML docs. - switch (document.contentType) { - case 'text/html': - case 'application/xhtml+xml': { - injectScriptSync(chrome.runtime.getURL('build/installHook.js')); - break; - } - } -} diff --git a/packages/react-devtools-shared/babel.config.js b/packages/react-devtools-shared/babel.config.js index ca877aa683..78af34817e 100644 --- a/packages/react-devtools-shared/babel.config.js +++ b/packages/react-devtools-shared/babel.config.js @@ -3,7 +3,7 @@ const firefoxManifest = require('../react-devtools-extensions/firefox/manifest.j const minChromeVersion = parseInt(chromeManifest.minimum_chrome_version, 10); const minFirefoxVersion = parseInt( - firefoxManifest.applications.gecko.strict_min_version, + firefoxManifest.browser_specific_settings.gecko.strict_min_version, 10, ); validateVersion(minChromeVersion);