Skip to content

Commit

Permalink
feat[react-devtools]: support Manifest v3 for Firefox extension (#30824)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
hoxyq authored Aug 29, 2024
1 parent fc0df47 commit 537c74e
Show file tree
Hide file tree
Showing 7 changed files with 66 additions and 175 deletions.
40 changes: 24 additions & 16 deletions packages/react-devtools-extensions/firefox/manifest.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -15,35 +15,43 @@
"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": [
"<all_urls>"
],
"extension_ids": []
}
],
"background": {
"scripts": [
"build/background.js"
]
},
"permissions": [
"file:///*",
"http://*/*",
"https://*/*",
"clipboardWrite",
"scripting",
"devtools"
"scripting"
],
"host_permissions": [
"<all_urls>"
],
"content_scripts": [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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: ['<all_urls>'],
persistAcrossSessions: true,
runAt: 'document_end',
},
{
id: '@react-devtools/file-fetcher',
js: ['build/fileFetcher.js'],
matches: ['<all_urls>'],
persistAcrossSessions: true,
runAt: 'document_end',
},
]
: [
{
id: '@react-devtools/proxy',
js: ['build/proxy.js'],
matches: ['<all_urls>'],
persistAcrossSessions: true,
runAt: 'document_end',
world: chrome.scripting.ExecutionWorld.ISOLATED,
},
{
id: '@react-devtools/file-fetcher',
js: ['build/fileFetcher.js'],
matches: ['<all_urls>'],
persistAcrossSessions: true,
runAt: 'document_end',
world: chrome.scripting.ExecutionWorld.ISOLATED,
},
{
id: '@react-devtools/hook',
js: ['build/installHook.js'],
matches: ['<all_urls>'],
persistAcrossSessions: true,
runAt: 'document_start',
world: chrome.scripting.ExecutionWorld.MAIN,
},
{
id: '@react-devtools/renderer',
js: ['build/renderer.js'],
matches: ['<all_urls>'],
persistAcrossSessions: true,
runAt: 'document_start',
world: chrome.scripting.ExecutionWorld.MAIN,
},
];
const contentScriptsToInject = [
{
id: '@react-devtools/proxy',
js: ['build/proxy.js'],
matches: ['<all_urls>'],
persistAcrossSessions: true,
runAt: 'document_end',
world: chrome.scripting.ExecutionWorld.ISOLATED,
},
{
id: '@react-devtools/file-fetcher',
js: ['build/fileFetcher.js'],
matches: ['<all_urls>'],
persistAcrossSessions: true,
runAt: 'document_end',
world: chrome.scripting.ExecutionWorld.ISOLATED,
},
{
id: '@react-devtools/hook',
js: ['build/installHook.js'],
matches: ['<all_urls>'],
persistAcrossSessions: true,
runAt: 'document_start',
world: chrome.scripting.ExecutionWorld.MAIN,
},
{
id: '@react-devtools/renderer',
js: ['build/renderer.js'],
matches: ['<all_urls>'],
persistAcrossSessions: true,
runAt: 'document_start',
world: chrome.scripting.ExecutionWorld.MAIN,
},
];

async function dynamicallyInjectContentScripts() {
try {
Expand All @@ -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
Expand Down
39 changes: 0 additions & 39 deletions packages/react-devtools-extensions/src/background/executeScript.js
Original file line number Diff line number Diff line change
@@ -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 <head> element is created,
// so we add the script to <html> 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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`),
Expand All @@ -15,7 +13,7 @@ function setExtensionIconAndPopup(reactBuildType, tabId) {
},
});

action.setPopup({
chrome.action.setPopup({
tabId,
popup: chrome.runtime.getURL(`popups/${reactBuildType}.html`),
});
Expand Down
24 changes: 5 additions & 19 deletions packages/react-devtools-extensions/src/background/tabsManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Original file line number Diff line number Diff line change
@@ -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 <head> element is created,
// so we add the script to <html> 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"
Expand Down Expand Up @@ -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;
}
}
}
2 changes: 1 addition & 1 deletion packages/react-devtools-shared/babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down

0 comments on commit 537c74e

Please sign in to comment.