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.
Ruslan Lesiutin committed
Aug 29, 2024 at 11:28 UTC
537c74e16a394df16a4b368caa09ea5755f78dfb
7 files changed
+66
-175
packages/react-devtools-extensions/firefox/manifest.json
+24
-16
@@ -1,12 +1,12 @@
1
{
2
- "manifest_version": 2,
2
+ "manifest_version": 3,
3
"name": "React Developer Tools",
4
"description": "Adds React debugging tools to the Firefox Developer Tools.",
5
"version": "5.3.1",
6
- "applications": {
6
+ "browser_specific_settings": {
7
"gecko": {
8
"id": "@react-devtools",
9
- "strict_min_version": "102.0"
9
+ "strict_min_version": "128.0"
10
}
11
},
12
"icons": {
@@ -15,22 +15,32 @@
15
"48": "icons/48-production.png",
16
"128": "icons/128-production.png"
17
},
18
- "browser_action": {
18
+ "action": {
19
"default_icon": {
20
"16": "icons/16-disabled.png",
21
"32": "icons/32-disabled.png",
22
"48": "icons/48-disabled.png",
23
"128": "icons/128-disabled.png"
24
},
25
- "default_popup": "popups/disabled.html",
26
- "browser_style": true
25
+ "default_popup": "popups/disabled.html"
26
},
27
"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
+ },
31
"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
+ }
44
],
45
"background": {
46
"scripts": [
@@ -38,12 +48,10 @@
48
]
49
},
50
"permissions": [
41
- "file:///*",
42
- "http://*/*",
43
- "https://*/*",
44
- "clipboardWrite",
45
- "scripting",
46
- "devtools"
51
+ "scripting"
52
+ ],
53
+ "host_permissions": [
54
+ "<all_urls>"
55
],
56
"content_scripts": [
57
{
packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js
+34
-56
@@ -1,58 +1,39 @@
1
/* global chrome */
2
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
+];
37
38
async function dynamicallyInjectContentScripts() {
39
try {
@@ -61,9 +42,6 @@ async function dynamicallyInjectContentScripts() {
42
// This fixes registering proxy content script in incognito mode
43
await chrome.scripting.unregisterContentScripts();
44
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
45
// Note: the "world" option in registerContentScripts is only available in Chrome v102+
46
// It's critical since it allows us to directly run scripts on the "main" world on the page
47
// "document_start" allows it to run before the page's scripts
packages/react-devtools-extensions/src/background/executeScript.js
-39
@@ -1,40 +1,5 @@
1
/* global chrome */
2
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
-
3
export function executeScriptInIsolatedWorld({target, files}) {
4
return chrome.scripting.executeScript({
5
target,
@@ -44,10 +9,6 @@ export function executeScriptInIsolatedWorld({target, files}) {
9
}
10
11
export function executeScriptInMainWorld({target, files}) {
47
- if (__IS_FIREFOX__) {
48
- return executeScriptForFirefoxInMainWorld({target, files});
49
- }
50
-
12
return chrome.scripting.executeScript({
13
target,
14
files,
packages/react-devtools-extensions/src/background/setExtensionIconAndPopup.js
+2
-4
@@ -3,9 +3,7 @@
3
'use strict';
4
5
function setExtensionIconAndPopup(reactBuildType, tabId) {
6
- const action = __IS_FIREFOX__ ? chrome.browserAction : chrome.action;
7
-
8
- action.setIcon({
6
+ chrome.action.setIcon({
7
tabId,
8
path: {
9
'16': chrome.runtime.getURL(`icons/16-${reactBuildType}.png`),
@@ -15,7 +13,7 @@ function setExtensionIconAndPopup(reactBuildType, tabId) {
13
},
14
});
15
18
- action.setPopup({
16
+ chrome.action.setPopup({
17
tabId,
18
popup: chrome.runtime.getURL(`popups/${reactBuildType}.html`),
19
});
packages/react-devtools-extensions/src/background/tabsManager.js
+5
-19
@@ -18,26 +18,12 @@ function checkAndHandleRestrictedPageIfSo(tab) {
18
// we can't update for any other types (prod,dev,outdated etc)
19
// as the content script needs to be injected at document_start itself for those kinds of detection
20
// 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
+);
25
26
// Listen to URL changes on the active tab and update the DevTools icon.
27
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);
29
});
packages/react-devtools-extensions/src/contentScripts/prepareInjection.js
-40
@@ -1,31 +1,5 @@
1
/* global chrome */
2
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
-
3
let lastSentDevToolsHookMessage;
4
5
// We want to detect when a renderer attaches, and notify the "background page"
@@ -60,17 +34,3 @@ window.addEventListener('pageshow', function ({target}) {
34
35
chrome.runtime.sendMessage(lastSentDevToolsHookMessage);
36
});
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
@@ -3,7 +3,7 @@ const firefoxManifest = require('../react-devtools-extensions/firefox/manifest.j
3
4
const minChromeVersion = parseInt(chromeManifest.minimum_chrome_version, 10);
5
const minFirefoxVersion = parseInt(
6
- firefoxManifest.applications.gecko.strict_min_version,
6
+ firefoxManifest.browser_specific_settings.gecko.strict_min_version,
7
10,
8
);
9
validateVersion(minChromeVersion);