Skip to content

Commit 537c74e

Browse files
authored
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.
1 parent fc0df47 commit 537c74e

File tree

7 files changed

+66
-175
lines changed

7 files changed

+66
-175
lines changed

packages/react-devtools-extensions/firefox/manifest.json

+24-16
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
{
2-
"manifest_version": 2,
2+
"manifest_version": 3,
33
"name": "React Developer Tools",
44
"description": "Adds React debugging tools to the Firefox Developer Tools.",
55
"version": "5.3.1",
6-
"applications": {
6+
"browser_specific_settings": {
77
"gecko": {
88
"id": "@react-devtools",
9-
"strict_min_version": "102.0"
9+
"strict_min_version": "128.0"
1010
}
1111
},
1212
"icons": {
@@ -15,35 +15,43 @@
1515
"48": "icons/48-production.png",
1616
"128": "icons/128-production.png"
1717
},
18-
"browser_action": {
18+
"action": {
1919
"default_icon": {
2020
"16": "icons/16-disabled.png",
2121
"32": "icons/32-disabled.png",
2222
"48": "icons/48-disabled.png",
2323
"128": "icons/128-disabled.png"
2424
},
25-
"default_popup": "popups/disabled.html",
26-
"browser_style": true
25+
"default_popup": "popups/disabled.html"
2726
},
2827
"devtools_page": "main.html",
29-
"content_security_policy": "script-src 'self' 'unsafe-eval' blob:; object-src 'self'",
28+
"content_security_policy": {
29+
"extension_pages": "script-src 'self'; object-src 'self'"
30+
},
3031
"web_accessible_resources": [
31-
"main.html",
32-
"panel.html",
33-
"build/*.js"
32+
{
33+
"resources": [
34+
"main.html",
35+
"panel.html",
36+
"build/*.js",
37+
"build/*.js.map"
38+
],
39+
"matches": [
40+
"<all_urls>"
41+
],
42+
"extension_ids": []
43+
}
3444
],
3545
"background": {
3646
"scripts": [
3747
"build/background.js"
3848
]
3949
},
4050
"permissions": [
41-
"file:///*",
42-
"http://*/*",
43-
"https://*/*",
44-
"clipboardWrite",
45-
"scripting",
46-
"devtools"
51+
"scripting"
52+
],
53+
"host_permissions": [
54+
"<all_urls>"
4755
],
4856
"content_scripts": [
4957
{

packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js

+34-56
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,39 @@
11
/* global chrome */
22

3-
// Firefox doesn't support ExecutionWorld.MAIN yet
4-
// equivalent logic for Firefox is in prepareInjection.js
5-
const contentScriptsToInject = __IS_FIREFOX__
6-
? [
7-
{
8-
id: '@react-devtools/proxy',
9-
js: ['build/proxy.js'],
10-
matches: ['<all_urls>'],
11-
persistAcrossSessions: true,
12-
runAt: 'document_end',
13-
},
14-
{
15-
id: '@react-devtools/file-fetcher',
16-
js: ['build/fileFetcher.js'],
17-
matches: ['<all_urls>'],
18-
persistAcrossSessions: true,
19-
runAt: 'document_end',
20-
},
21-
]
22-
: [
23-
{
24-
id: '@react-devtools/proxy',
25-
js: ['build/proxy.js'],
26-
matches: ['<all_urls>'],
27-
persistAcrossSessions: true,
28-
runAt: 'document_end',
29-
world: chrome.scripting.ExecutionWorld.ISOLATED,
30-
},
31-
{
32-
id: '@react-devtools/file-fetcher',
33-
js: ['build/fileFetcher.js'],
34-
matches: ['<all_urls>'],
35-
persistAcrossSessions: true,
36-
runAt: 'document_end',
37-
world: chrome.scripting.ExecutionWorld.ISOLATED,
38-
},
39-
{
40-
id: '@react-devtools/hook',
41-
js: ['build/installHook.js'],
42-
matches: ['<all_urls>'],
43-
persistAcrossSessions: true,
44-
runAt: 'document_start',
45-
world: chrome.scripting.ExecutionWorld.MAIN,
46-
},
47-
{
48-
id: '@react-devtools/renderer',
49-
js: ['build/renderer.js'],
50-
matches: ['<all_urls>'],
51-
persistAcrossSessions: true,
52-
runAt: 'document_start',
53-
world: chrome.scripting.ExecutionWorld.MAIN,
54-
},
55-
];
3+
const contentScriptsToInject = [
4+
{
5+
id: '@react-devtools/proxy',
6+
js: ['build/proxy.js'],
7+
matches: ['<all_urls>'],
8+
persistAcrossSessions: true,
9+
runAt: 'document_end',
10+
world: chrome.scripting.ExecutionWorld.ISOLATED,
11+
},
12+
{
13+
id: '@react-devtools/file-fetcher',
14+
js: ['build/fileFetcher.js'],
15+
matches: ['<all_urls>'],
16+
persistAcrossSessions: true,
17+
runAt: 'document_end',
18+
world: chrome.scripting.ExecutionWorld.ISOLATED,
19+
},
20+
{
21+
id: '@react-devtools/hook',
22+
js: ['build/installHook.js'],
23+
matches: ['<all_urls>'],
24+
persistAcrossSessions: true,
25+
runAt: 'document_start',
26+
world: chrome.scripting.ExecutionWorld.MAIN,
27+
},
28+
{
29+
id: '@react-devtools/renderer',
30+
js: ['build/renderer.js'],
31+
matches: ['<all_urls>'],
32+
persistAcrossSessions: true,
33+
runAt: 'document_start',
34+
world: chrome.scripting.ExecutionWorld.MAIN,
35+
},
36+
];
5637

5738
async function dynamicallyInjectContentScripts() {
5839
try {
@@ -61,9 +42,6 @@ async function dynamicallyInjectContentScripts() {
6142
// This fixes registering proxy content script in incognito mode
6243
await chrome.scripting.unregisterContentScripts();
6344

64-
// equivalent logic for Firefox is in prepareInjection.js
65-
// Manifest V3 method of injecting content script
66-
// TODO(hoxyq): migrate Firefox to V3 manifests
6745
// Note: the "world" option in registerContentScripts is only available in Chrome v102+
6846
// It's critical since it allows us to directly run scripts on the "main" world on the page
6947
// "document_start" allows it to run before the page's scripts

packages/react-devtools-extensions/src/background/executeScript.js

-39
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,5 @@
11
/* global chrome */
22

3-
// Firefox doesn't support ExecutionWorld.MAIN yet
4-
// https://bugzilla.mozilla.org/show_bug.cgi?id=1736575
5-
function executeScriptForFirefoxInMainWorld({target, files}) {
6-
return chrome.scripting.executeScript({
7-
target,
8-
func: fileNames => {
9-
function injectScriptSync(src) {
10-
let code = '';
11-
const request = new XMLHttpRequest();
12-
request.addEventListener('load', function () {
13-
code = this.responseText;
14-
});
15-
request.open('GET', src, false);
16-
request.send();
17-
18-
const script = document.createElement('script');
19-
script.textContent = code;
20-
21-
// This script runs before the <head> element is created,
22-
// so we add the script to <html> instead.
23-
if (document.documentElement) {
24-
document.documentElement.appendChild(script);
25-
}
26-
27-
if (script.parentNode) {
28-
script.parentNode.removeChild(script);
29-
}
30-
}
31-
32-
fileNames.forEach(file => injectScriptSync(chrome.runtime.getURL(file)));
33-
},
34-
args: [files],
35-
});
36-
}
37-
383
export function executeScriptInIsolatedWorld({target, files}) {
394
return chrome.scripting.executeScript({
405
target,
@@ -44,10 +9,6 @@ export function executeScriptInIsolatedWorld({target, files}) {
449
}
4510

4611
export function executeScriptInMainWorld({target, files}) {
47-
if (__IS_FIREFOX__) {
48-
return executeScriptForFirefoxInMainWorld({target, files});
49-
}
50-
5112
return chrome.scripting.executeScript({
5213
target,
5314
files,

packages/react-devtools-extensions/src/background/setExtensionIconAndPopup.js

+2-4
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@
33
'use strict';
44

55
function setExtensionIconAndPopup(reactBuildType, tabId) {
6-
const action = __IS_FIREFOX__ ? chrome.browserAction : chrome.action;
7-
8-
action.setIcon({
6+
chrome.action.setIcon({
97
tabId,
108
path: {
119
'16': chrome.runtime.getURL(`icons/16-${reactBuildType}.png`),
@@ -15,7 +13,7 @@ function setExtensionIconAndPopup(reactBuildType, tabId) {
1513
},
1614
});
1715

18-
action.setPopup({
16+
chrome.action.setPopup({
1917
tabId,
2018
popup: chrome.runtime.getURL(`popups/${reactBuildType}.html`),
2119
});

packages/react-devtools-extensions/src/background/tabsManager.js

+5-19
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,12 @@ function checkAndHandleRestrictedPageIfSo(tab) {
1818
// we can't update for any other types (prod,dev,outdated etc)
1919
// as the content script needs to be injected at document_start itself for those kinds of detection
2020
// TODO: Show a different popup page(to reload current page probably) for old tabs, opened before the extension is installed
21-
if (__IS_CHROME__ || __IS_EDGE__) {
22-
chrome.tabs.query({}, tabs => tabs.forEach(checkAndHandleRestrictedPageIfSo));
23-
chrome.tabs.onCreated.addListener((tabId, changeInfo, tab) =>
24-
checkAndHandleRestrictedPageIfSo(tab),
25-
);
26-
}
21+
chrome.tabs.query({}, tabs => tabs.forEach(checkAndHandleRestrictedPageIfSo));
22+
chrome.tabs.onCreated.addListener((tabId, changeInfo, tab) =>
23+
checkAndHandleRestrictedPageIfSo(tab),
24+
);
2725

2826
// Listen to URL changes on the active tab and update the DevTools icon.
2927
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
30-
if (__IS_FIREFOX__) {
31-
// We don't properly detect protected URLs in Firefox at the moment.
32-
// However, we can reset the DevTools icon to its loading state when the URL changes.
33-
// It will be updated to the correct icon by the onMessage callback below.
34-
if (tab.active && changeInfo.status === 'loading') {
35-
setExtensionIconAndPopup('disabled', tabId);
36-
}
37-
} else {
38-
// Don't reset the icon to the loading state for Chrome or Edge.
39-
// The onUpdated callback fires more frequently for these browsers,
40-
// often after onMessage has been called.
41-
checkAndHandleRestrictedPageIfSo(tab);
42-
}
28+
checkAndHandleRestrictedPageIfSo(tab);
4329
});
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,5 @@
11
/* global chrome */
22

3-
import nullthrows from 'nullthrows';
4-
5-
// We run scripts on the page via the service worker (background/index.js) for
6-
// Manifest V3 extensions (Chrome & Edge).
7-
// We need to inject this code for Firefox only because it does not support ExecutionWorld.MAIN
8-
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld
9-
// In this content script we have access to DOM, but don't have access to the webpage's window,
10-
// so we inject this inline script tag into the webpage (allowed in Manifest V2).
11-
function injectScriptSync(src) {
12-
let code = '';
13-
const request = new XMLHttpRequest();
14-
request.addEventListener('load', function () {
15-
code = this.responseText;
16-
});
17-
request.open('GET', src, false);
18-
request.send();
19-
20-
const script = document.createElement('script');
21-
script.textContent = code;
22-
23-
// This script runs before the <head> element is created,
24-
// so we add the script to <html> instead.
25-
nullthrows(document.documentElement).appendChild(script);
26-
nullthrows(script.parentNode).removeChild(script);
27-
}
28-
293
let lastSentDevToolsHookMessage;
304

315
// We want to detect when a renderer attaches, and notify the "background page"
@@ -60,17 +34,3 @@ window.addEventListener('pageshow', function ({target}) {
6034

6135
chrome.runtime.sendMessage(lastSentDevToolsHookMessage);
6236
});
63-
64-
if (__IS_FIREFOX__) {
65-
injectScriptSync(chrome.runtime.getURL('build/renderer.js'));
66-
67-
// Inject a __REACT_DEVTOOLS_GLOBAL_HOOK__ global for React to interact with.
68-
// Only do this for HTML documents though, to avoid e.g. breaking syntax highlighting for XML docs.
69-
switch (document.contentType) {
70-
case 'text/html':
71-
case 'application/xhtml+xml': {
72-
injectScriptSync(chrome.runtime.getURL('build/installHook.js'));
73-
break;
74-
}
75-
}
76-
}

packages/react-devtools-shared/babel.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ const firefoxManifest = require('../react-devtools-extensions/firefox/manifest.j
33

44
const minChromeVersion = parseInt(chromeManifest.minimum_chrome_version, 10);
55
const minFirefoxVersion = parseInt(
6-
firefoxManifest.applications.gecko.strict_min_version,
6+
firefoxManifest.browser_specific_settings.gecko.strict_min_version,
77
10,
88
);
99
validateVersion(minChromeVersion);

0 commit comments

Comments
 (0)