Upgrade to Manifest V3 (#1714)

* Update Chrome manifest.json

* Remove use of window in background

* Test devpanel

* Inject pageScript using new API

* Keep connection from devpanel to background alive

* Keep connection from content script to background alive

* Replace page action with action

* Cleanup syncOptions

* Update options to not rely on background page access

* Start work on updating popup

* Updates

* Remove window

* Get opening in a separate window working

* Remove pageScriptWrap

* Add socket to panelStore

* Fix tests

* Try to use MV3 for Firefox

* Fix path

* Fix Chrome E2E tests

* Revert unintentional change

* Skip Electron tests for now

Looks like they're still working through stuff in https://github.com/electron/electron/issues/41613

* Better image centering

The Firefox popup did not like the old CSS. This is still not perfect, but it's better than it was.

* Create shaggy-taxis-cross.md
This commit is contained in:
Nathan Bierema 2024-08-17 15:11:46 -04:00 committed by GitHub
parent 61ec00f505
commit 83b2c19a11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 347 additions and 766 deletions

View File

@ -0,0 +1,5 @@
---
'remotedev-redux-devtools-extension': minor
---
Upgrade to Manifest V3

View File

@ -5,7 +5,7 @@ import pug from 'pug';
const args = process.argv.slice(2); const args = process.argv.slice(2);
const prod = !args.includes('--dev'); const prod = !args.includes('--dev');
const commonEsbuildOptions = { await esbuild.build({
bundle: true, bundle: true,
logLevel: 'info', logLevel: 'info',
outdir: 'dist', outdir: 'dist',
@ -15,40 +15,24 @@ const commonEsbuildOptions = {
'process.env.NODE_ENV': prod ? '"production"' : '"development"', 'process.env.NODE_ENV': prod ? '"production"' : '"development"',
'process.env.BABEL_ENV': prod ? '"production"' : '"development"', 'process.env.BABEL_ENV': prod ? '"production"' : '"development"',
}, },
};
await esbuild.build({
...commonEsbuildOptions,
entryPoints: [ entryPoints: [
{ out: 'background.bundle', in: 'src/background/index.ts' }, { out: 'background.bundle', in: 'src/background/index.ts' },
{ out: 'options.bundle', in: 'src/options/index.tsx' }, { out: 'options.bundle', in: 'src/options/index.tsx' },
{ out: 'window.bundle', in: 'src/window/index.tsx' },
{ out: 'remote.bundle', in: 'src/remote/index.tsx' }, { out: 'remote.bundle', in: 'src/remote/index.tsx' },
{ out: 'devpanel.bundle', in: 'src/devpanel/index.tsx' }, { out: 'devpanel.bundle', in: 'src/devpanel/index.tsx' },
{ out: 'devtools.bundle', in: 'src/devtools/index.ts' }, { out: 'devtools.bundle', in: 'src/devtools/index.ts' },
{ out: 'content.bundle', in: 'src/contentScript/index.ts' }, { out: 'content.bundle', in: 'src/contentScript/index.ts' },
{ out: 'page.bundle', in: 'src/pageScript/index.ts' }, { out: 'page.bundle', in: 'src/pageScript/index.ts' },
...(prod ? [] : [{ out: 'pagewrap.bundle', in: 'src/pageScriptWrap.ts' }]),
], ],
loader: { loader: {
'.woff2': 'file', '.woff2': 'file',
}, },
}); });
if (prod) {
await esbuild.build({
...commonEsbuildOptions,
entryPoints: [{ out: 'pagewrap.bundle', in: 'src/pageScriptWrap.ts' }],
loader: {
'.js': 'text',
},
});
}
console.log(); console.log();
console.log('Creating HTML files...'); console.log('Creating HTML files...');
const htmlFiles = ['devpanel', 'devtools', 'options', 'remote', 'window']; const htmlFiles = ['devpanel', 'devtools', 'options', 'remote'];
for (const htmlFile of htmlFiles) { for (const htmlFile of htmlFiles) {
fs.writeFileSync( fs.writeFileSync(
`dist/${htmlFile}.html`, `dist/${htmlFile}.html`,

View File

@ -3,26 +3,20 @@
"name": "Redux DevTools", "name": "Redux DevTools",
"description": "Redux DevTools for debugging application's state changes.", "description": "Redux DevTools for debugging application's state changes.",
"homepage_url": "https://github.com/reduxjs/redux-devtools", "homepage_url": "https://github.com/reduxjs/redux-devtools",
"manifest_version": 2, "manifest_version": 3,
"page_action": { "action": {
"default_icon": "img/logo/gray.png", "default_icon": "img/logo/gray.png",
"default_title": "Redux DevTools", "default_title": "Redux DevTools",
"default_popup": "window.html#popup" "default_popup": "devpanel.html#popup"
}, },
"commands": { "commands": {
"devtools-left": { "devtools-window": {
"description": "DevTools window to left" "description": "DevTools window"
},
"devtools-right": {
"description": "DevTools window to right"
},
"devtools-bottom": {
"description": "DevTools window to bottom"
}, },
"devtools-remote": { "devtools-remote": {
"description": "Remote DevTools" "description": "Remote DevTools"
}, },
"_execute_page_action": { "_execute_action": {
"suggested_key": { "suggested_key": {
"default": "Ctrl+Shift+E" "default": "Ctrl+Shift+E"
} }
@ -34,36 +28,37 @@
"128": "img/logo/128x128.png" "128": "img/logo/128x128.png"
}, },
"options_ui": { "options_ui": {
"page": "options.html", "page": "options.html"
"chrome_style": true
}, },
"background": { "background": {
"scripts": ["background.bundle.js"], "service_worker": "background.bundle.js"
"persistent": false
}, },
"content_scripts": [ "content_scripts": [
{ {
"matches": ["<all_urls>"], "matches": ["<all_urls>"],
"exclude_globs": ["https://www.google*"], "exclude_globs": ["https://www.google*"],
"js": ["content.bundle.js", "pagewrap.bundle.js"], "js": ["content.bundle.js"],
"run_at": "document_start", "run_at": "document_start",
"all_frames": true "all_frames": true
},
{
"matches": ["<all_urls>"],
"exclude_globs": ["https://www.google*"],
"js": ["page.bundle.js"],
"run_at": "document_start",
"all_frames": true,
"world": "MAIN"
} }
], ],
"devtools_page": "devtools.html", "devtools_page": "devtools.html",
"web_accessible_resources": ["page.bundle.js"],
"externally_connectable": { "externally_connectable": {
"ids": ["*"] "ids": ["*"]
}, },
"permissions": [ "permissions": ["notifications", "contextMenus", "storage"],
"notifications", "host_permissions": ["file:///*", "http://*/*", "https://*/*"],
"contextMenus", "content_security_policy": {
"storage", "extension_pages": "script-src 'self'; object-src 'self'; style-src * 'unsafe-inline'; img-src 'self' data:;"
"file:///*", },
"http://*/*",
"https://*/*"
],
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'; style-src * 'unsafe-inline'; img-src 'self' data:;",
"update_url": "https://clients2.google.com/service/update2/crx", "update_url": "https://clients2.google.com/service/update2/crx",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsdJEPwY92xUACA9CcDBDBmbdbp8Ap3cKQ0DJTUuVQvqb4FQAv8RtKY3iUjGvdwuAcSJQIZwHXcP2aNDH3TiFik/NhRK2GRW8X3OZyTdkuDueABGP2KEX8q1WQDgjX/rPIinGYztUrvoICw/UerMPwNW62jwGoVU3YhAGf+15CgX2Y6a4tppnf/+1mPedKPidh0RsM+aJY98rX+r1SPAHPcGzMjocLkqcT75DZBXer8VQN14tOOzRCd6T6oy7qm7eWru8lJwcY66qMQvhk0osqEod2G3nA7aTWpmqPFS66VEiecP9PgZlp8gQdgZ3dFhA62exydlD55JuRhiMIR63yQIDAQAB" "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsdJEPwY92xUACA9CcDBDBmbdbp8Ap3cKQ0DJTUuVQvqb4FQAv8RtKY3iUjGvdwuAcSJQIZwHXcP2aNDH3TiFik/NhRK2GRW8X3OZyTdkuDueABGP2KEX8q1WQDgjX/rPIinGYztUrvoICw/UerMPwNW62jwGoVU3YhAGf+15CgX2Y6a4tppnf/+1mPedKPidh0RsM+aJY98rX+r1SPAHPcGzMjocLkqcT75DZBXer8VQN14tOOzRCd6T6oy7qm7eWru8lJwcY66qMQvhk0osqEod2G3nA7aTWpmqPFS66VEiecP9PgZlp8gQdgZ3dFhA62exydlD55JuRhiMIR63yQIDAQAB"
} }

View File

@ -1,28 +1,22 @@
{ {
"version": "3.1.10", "version": "3.1.10",
"name": "Redux DevTools", "name": "Redux DevTools",
"manifest_version": 2, "manifest_version": 3,
"description": "Redux Developer Tools for debugging application state changes.", "description": "Redux Developer Tools for debugging application state changes.",
"homepage_url": "https://github.com/reduxjs/redux-devtools", "homepage_url": "https://github.com/reduxjs/redux-devtools",
"applications": { "browser_specific_settings": {
"gecko": { "gecko": {
"id": "extension@redux.devtools" "id": "extension@redux.devtools"
} }
}, },
"page_action": { "action": {
"default_icon": "img/logo/38x38.png", "default_icon": "img/logo/38x38.png",
"default_title": "Redux DevTools", "default_title": "Redux DevTools",
"default_popup": "window.html#popup" "default_popup": "devpanel.html#popup"
}, },
"commands": { "commands": {
"devtools-left": { "devtools-window": {
"description": "DevTools window to left" "description": "DevTools window"
},
"devtools-right": {
"description": "DevTools window to right"
},
"devtools-bottom": {
"description": "DevTools window to bottom"
}, },
"devtools-remote": { "devtools-remote": {
"description": "Remote DevTools" "description": "Remote DevTools"
@ -42,21 +36,22 @@
"content_scripts": [ "content_scripts": [
{ {
"matches": ["<all_urls>"], "matches": ["<all_urls>"],
"js": ["content.bundle.js", "pagewrap.bundle.js"], "js": ["content.bundle.js"],
"run_at": "document_start", "run_at": "document_start",
"all_frames": true "all_frames": true
},
{
"matches": ["<all_urls>"],
"js": ["page.bundle.js"],
"run_at": "document_start",
"all_frames": true,
"world": "MAIN"
} }
], ],
"devtools_page": "devtools.html", "devtools_page": "devtools.html",
"web_accessible_resources": ["page.bundle.js"], "permissions": ["notifications", "contextMenus", "tabs", "storage"],
"permissions": [ "host_permissions": ["file:///*", "http://*/*", "https://*/*"],
"notifications", "content_security_policy": {
"contextMenus", "extension_pages": "script-src 'self'; object-src 'self'; img-src 'self' data:;"
"tabs", }
"storage",
"file:///*",
"http://*/*",
"https://*/*"
],
"content_security_policy": "script-src 'self'; object-src 'self'; img-src 'self' data:;"
} }

View File

@ -18,7 +18,7 @@ import {
TopButtons, TopButtons,
} from '@redux-devtools/app'; } from '@redux-devtools/app';
import { GoBroadcast } from 'react-icons/go'; import { GoBroadcast } from 'react-icons/go';
import { MdBorderBottom, MdBorderLeft, MdBorderRight } from 'react-icons/md'; import { MdOutlineWindow } from 'react-icons/md';
import type { Position } from '../pageScript/api/openWindow'; import type { Position } from '../pageScript/api/openWindow';
import type { SingleMessage } from '../background/store/apiMiddleware'; import type { SingleMessage } from '../background/store/apiMiddleware';
@ -98,31 +98,13 @@ class Actions extends Component<Props> {
<DispatcherButton dispatcherIsOpen={this.props.dispatcherIsOpen} /> <DispatcherButton dispatcherIsOpen={this.props.dispatcherIsOpen} />
)} )}
<Divider /> <Divider />
{!window.isElectron && position !== '#left' && ( {!window.isElectron && (
<Button <Button
onClick={() => { onClick={() => {
this.openWindow('left'); this.openWindow('window');
}} }}
> >
<MdBorderLeft /> <MdOutlineWindow />
</Button>
)}
{!window.isElectron && position !== '#right' && (
<Button
onClick={() => {
this.openWindow('right');
}}
>
<MdBorderRight />
</Button>
)}
{!window.isElectron && position !== '#bottom' && (
<Button
onClick={() => {
this.openWindow('bottom');
}}
>
<MdBorderBottom />
</Button> </Button>
)} )}
{!window.isElectron && ( {!window.isElectron && (

View File

@ -2,29 +2,23 @@ import openDevToolsWindow, { DevToolsPosition } from './openWindow';
export function createMenu() { export function createMenu() {
const menus = [ const menus = [
{ id: 'devtools-left', title: 'To left' }, { id: 'devtools-window', title: 'Open in a window' },
{ id: 'devtools-right', title: 'To right' },
{ id: 'devtools-bottom', title: 'To bottom' },
{
id: 'devtools-panel',
title: 'Open in a panel (enable in browser settings)',
},
{ id: 'devtools-remote', title: 'Open Remote DevTools' }, { id: 'devtools-remote', title: 'Open Remote DevTools' },
]; ];
let shortcuts: { [commandName: string]: string | undefined } = {}; let shortcuts: { [commandName: string]: string | undefined } = {};
chrome.commands.getAll((commands) => { chrome.commands.getAll((commands) => {
commands.forEach(({ name, shortcut }) => { for (const { name, shortcut } of commands) {
shortcuts[name!] = shortcut; shortcuts[name!] = shortcut;
}); }
menus.forEach(({ id, title }) => { for (const { id, title } of menus) {
chrome.contextMenus.create({ chrome.contextMenus.create({
id: id, id: id,
title: title + (shortcuts[id] ? ' (' + shortcuts[id] + ')' : ''), title: title + (shortcuts[id] ? ' (' + shortcuts[id] + ')' : ''),
contexts: ['all'], contexts: ['all'],
}); });
}); }
}); });
} }

View File

@ -1,29 +1,22 @@
import '../chromeApiMock'; import configureStore from './store/backgroundStore';
import { Store } from 'redux';
import configureStore, { BackgroundAction } from './store/backgroundStore';
import openDevToolsWindow, { DevToolsPosition } from './openWindow'; import openDevToolsWindow, { DevToolsPosition } from './openWindow';
import { createMenu, removeMenu } from './contextMenus'; import { createMenu, removeMenu } from './contextMenus';
import syncOptions from '../options/syncOptions'; import { getOptions } from '../options/syncOptions';
import { BackgroundState } from './store/backgroundReducer';
declare global {
interface Window {
store: Store<BackgroundState, BackgroundAction>;
}
}
// Expose the extension's store globally to access it from the windows // Expose the extension's store globally to access it from the windows
// via chrome.runtime.getBackgroundPage // via chrome.runtime.getBackgroundPage
window.store = configureStore(); export const store = configureStore();
// Listen for keyboard shortcuts // Listen for keyboard shortcuts
chrome.commands.onCommand.addListener((shortcut) => { chrome.commands.onCommand.addListener((shortcut) => {
openDevToolsWindow(shortcut as DevToolsPosition); openDevToolsWindow(shortcut as DevToolsPosition);
}); });
// Create the context menu when installed // Disable the action by default and create the context menu when installed
chrome.runtime.onInstalled.addListener(() => { chrome.runtime.onInstalled.addListener(() => {
syncOptions().get((option) => { chrome.action.disable();
getOptions((option) => {
if (option.showContextMenus) createMenu(); if (option.showContextMenus) createMenu();
}); });
}); });

View File

@ -1,4 +1,5 @@
import { LIFTED_ACTION } from '@redux-devtools/app'; import { LIFTED_ACTION } from '@redux-devtools/app';
import { store } from './index';
export function getReport( export function getReport(
reportId: string, reportId: string,
@ -24,7 +25,7 @@ export function getReport(
.then((json) => { .then((json) => {
const { payload, preloadedState } = json; const { payload, preloadedState } = json;
if (!payload) return; if (!payload) return;
window.store.dispatch({ store.dispatch({
type: LIFTED_ACTION, type: LIFTED_ACTION,
message: 'IMPORT', message: 'IMPORT',
state: JSON.stringify({ payload, preloadedState }), state: JSON.stringify({ payload, preloadedState }),

View File

@ -1,83 +1,34 @@
export type DevToolsPosition = export type DevToolsPosition = 'devtools-window' | 'devtools-remote';
| 'devtools-left'
| 'devtools-right'
| 'devtools-bottom'
| 'devtools-panel'
| 'devtools-remote';
let windows: { [K in DevToolsPosition]?: number } = {}; let windows: { [K in DevToolsPosition]?: number } = {};
let lastPosition: DevToolsPosition | null = null;
export default function openDevToolsWindow(position: DevToolsPosition) { export default function openDevToolsWindow(position: DevToolsPosition) {
function popWindow( if (!windows[position]) {
action: string, createWindow(position);
url: string, } else {
customOptions: chrome.windows.CreateData & chrome.windows.UpdateInfo, chrome.windows.update(windows[position]!, { focused: true }, () => {
) { if (chrome.runtime.lastError) createWindow(position);
function focusIfExist(callback: () => void) {
if (!windows[position]) {
callback();
lastPosition = position;
} else {
let params = { focused: true };
if (lastPosition !== position && position !== 'devtools-panel') {
params = { ...params, ...customOptions };
}
chrome.windows.update(windows[position]!, params, () => {
lastPosition = null;
if (chrome.runtime.lastError) callback();
});
}
}
focusIfExist(() => {
let options: chrome.windows.CreateData = {
type: 'popup',
...customOptions,
};
if (action === 'open') {
options.url = chrome.extension.getURL(
url + '#' + position.substr(position.indexOf('-') + 1),
);
chrome.windows.create(options, (win) => {
windows[position] = win!.id;
if (navigator.userAgent.indexOf('Firefox') !== -1) {
chrome.windows.update(win!.id!, {
focused: true,
...customOptions,
});
}
});
}
}); });
} }
}
let params: chrome.windows.CreateData & chrome.windows.UpdateInfo = {
left: 0, function createWindow(position: DevToolsPosition) {
top: 0, const url = chrome.runtime.getURL(getPath(position));
width: 380, chrome.windows.create({ type: 'popup', url }, (win) => {
height: window.screen.availHeight, windows[position] = win!.id;
}; if (navigator.userAgent.indexOf('Firefox') !== -1) {
let url = 'window.html'; chrome.windows.update(win!.id!, { focused: true });
switch (position) { }
case 'devtools-right': });
params.left = }
(window.screen as unknown as { availLeft: number }).availLeft +
window.screen.availWidth - function getPath(position: DevToolsPosition) {
params.width!; switch (position) {
break; case 'devtools-window':
case 'devtools-bottom': return 'devpanel.html';
params.height = 420; case 'devtools-remote':
params.top = window.screen.height - params.height; return 'remote.html';
params.width = window.screen.availWidth; default:
break; throw new Error(`Unrecognized position: ${position}`);
case 'devtools-panel': }
params.type = 'panel';
break;
case 'devtools-remote':
params = { width: 850, height: 600 };
url = 'remote.html';
break;
}
popWindow('open', url, params);
} }

View File

@ -11,11 +11,7 @@ import {
TOGGLE_PERSIST, TOGGLE_PERSIST,
UPDATE_STATE, UPDATE_STATE,
} from '@redux-devtools/app'; } from '@redux-devtools/app';
import syncOptions, { import type { Options, OptionsMessage } from '../../options/syncOptions';
Options,
OptionsMessage,
SyncOptions,
} from '../../options/syncOptions';
import openDevToolsWindow, { DevToolsPosition } from '../openWindow'; import openDevToolsWindow, { DevToolsPosition } from '../openWindow';
import { getReport } from '../logging'; import { getReport } from '../logging';
import { Action, Dispatch, Middleware } from 'redux'; import { Action, Dispatch, Middleware } from 'redux';
@ -32,6 +28,7 @@ import { LiftedState } from '@redux-devtools/instrument';
import type { BackgroundAction, LiftedActionAction } from './backgroundStore'; import type { BackgroundAction, LiftedActionAction } from './backgroundStore';
import type { Position } from '../../pageScript/api/openWindow'; import type { Position } from '../../pageScript/api/openWindow';
import type { BackgroundState } from './backgroundReducer'; import type { BackgroundState } from './backgroundReducer';
import { store } from '../index';
interface TabMessageBase { interface TabMessageBase {
readonly type: string; readonly type: string;
@ -51,6 +48,11 @@ interface StopAction extends TabMessageBase {
readonly id?: never; readonly id?: never;
} }
interface OptionsAction {
readonly type: 'OPTIONS';
readonly options: Options;
}
interface DispatchAction extends TabMessageBase { interface DispatchAction extends TabMessageBase {
readonly type: 'DISPATCH'; readonly type: 'DISPATCH';
readonly action: AppDispatchAction; readonly action: AppDispatchAction;
@ -196,7 +198,7 @@ interface SplitUpdateStateAction<S, A extends Action<string>> {
export type TabMessage = export type TabMessage =
| StartAction | StartAction
| StopAction | StopAction
| OptionsMessage | OptionsAction
| DispatchAction | DispatchAction
| ImportAction | ImportAction
| ActionAction | ActionAction
@ -247,7 +249,6 @@ const chunks: {
>; >;
} = {}; } = {};
let monitors = 0; let monitors = 0;
let isMonitored = false;
const getId = (sender: chrome.runtime.MessageSender, name?: string) => const getId = (sender: chrome.runtime.MessageSender, name?: string) =>
sender.tab ? sender.tab.id! : name || sender.id!; sender.tab ? sender.tab.id! : name || sender.id!;
@ -261,22 +262,18 @@ type MonitorAction<S, A extends Action<string>> =
// Chrome message limit is 64 MB, but we're using 32 MB to include other object's parts // Chrome message limit is 64 MB, but we're using 32 MB to include other object's parts
const maxChromeMsgSize = 32 * 1024 * 1024; const maxChromeMsgSize = 32 * 1024 * 1024;
// TODO Clean up args
function toMonitors<S, A extends Action<string>>( function toMonitors<S, A extends Action<string>>(
action: MonitorAction<S, A>, action: MonitorAction<S, A>,
tabId?: string | number, tabId?: string | number,
verbose?: boolean, verbose?: boolean,
) { ) {
for (const monitorPort of Object.values(connections.monitor)) { for (const port of [
monitorPort.postMessage( ...Object.values(connections.monitor),
verbose || action.type === 'ERROR' || action.type === SET_PERSIST ...Object.values(connections.panel),
? action ]) {
: { type: UPDATE_STATE },
);
}
for (const panelPort of Object.values(connections.panel)) {
try { try {
panelPort.postMessage(action); port.postMessage(action);
} catch (err) { } catch (err) {
if ( if (
action.type !== UPDATE_STATE || action.type !== UPDATE_STATE ||
@ -307,11 +304,11 @@ function toMonitors<S, A extends Action<string>>(
value; value;
} }
panelPort.postMessage({ ...action, request: splitMessageStart }); port.postMessage({ ...action, request: splitMessageStart });
for (let i = 0; i < toSplit.length; i++) { for (let i = 0; i < toSplit.length; i++) {
for (let j = 0; j < toSplit[i][1].length; j += maxChromeMsgSize) { for (let j = 0; j < toSplit[i][1].length; j += maxChromeMsgSize) {
panelPort.postMessage({ port.postMessage({
...action, ...action,
request: { request: {
split: 'chunk', split: 'chunk',
@ -324,7 +321,7 @@ function toMonitors<S, A extends Action<string>>(
} }
} }
panelPort.postMessage({ ...action, request: { split: 'end' } }); port.postMessage({ ...action, request: { split: 'end' } });
} }
} }
} }
@ -346,7 +343,7 @@ function toContentScript(messageBody: ToContentScriptMessage) {
type: message, type: message,
action, action,
state: nonReduxDispatch( state: nonReduxDispatch(
window.store, store,
message, message,
instanceId, instanceId,
action as AppDispatchAction, action as AppDispatchAction,
@ -360,7 +357,7 @@ function toContentScript(messageBody: ToContentScriptMessage) {
type: message, type: message,
action, action,
state: nonReduxDispatch( state: nonReduxDispatch(
window.store, store,
message, message,
instanceId, instanceId,
action as unknown as AppDispatchAction, action as unknown as AppDispatchAction,
@ -374,7 +371,7 @@ function toContentScript(messageBody: ToContentScriptMessage) {
type: message, type: message,
action, action,
state: nonReduxDispatch( state: nonReduxDispatch(
window.store, store,
message, message,
instanceId, instanceId,
action as unknown as AppDispatchAction, action as unknown as AppDispatchAction,
@ -388,7 +385,7 @@ function toContentScript(messageBody: ToContentScriptMessage) {
type: message, type: message,
action, action,
state: nonReduxDispatch( state: nonReduxDispatch(
window.store, store,
message, message,
instanceId, instanceId,
action as unknown as AppDispatchAction, action as unknown as AppDispatchAction,
@ -402,7 +399,7 @@ function toContentScript(messageBody: ToContentScriptMessage) {
type: message, type: message,
action, action,
state: nonReduxDispatch( state: nonReduxDispatch(
window.store, store,
message, message,
instanceId, instanceId,
action as AppDispatchAction, action as AppDispatchAction,
@ -414,14 +411,12 @@ function toContentScript(messageBody: ToContentScriptMessage) {
} }
function toAllTabs(msg: TabMessage) { function toAllTabs(msg: TabMessage) {
const tabs = connections.tab; for (const tabPort of Object.values(connections.tab)) {
Object.keys(tabs).forEach((id) => { tabPort.postMessage(msg);
tabs[id].postMessage(msg); }
});
} }
function monitorInstances(shouldMonitor: boolean, id?: string) { function monitorInstances(shouldMonitor: boolean, id?: string) {
if (!id && isMonitored === shouldMonitor) return;
const action = { const action = {
type: shouldMonitor ? ('START' as const) : ('STOP' as const), type: shouldMonitor ? ('START' as const) : ('STOP' as const),
}; };
@ -430,11 +425,10 @@ function monitorInstances(shouldMonitor: boolean, id?: string) {
} else { } else {
toAllTabs(action); toAllTabs(action);
} }
isMonitored = shouldMonitor;
} }
function getReducerError() { function getReducerError() {
const instancesState = window.store.getState().instances; const instancesState = store.getState().instances;
const payload = instancesState.states[instancesState.current]; const payload = instancesState.states[instancesState.current];
const computedState = payload.computedStates[payload.currentStateIndex]; const computedState = payload.computedStates[payload.currentStateIndex];
if (!computedState) return false; if (!computedState) return false;
@ -442,11 +436,11 @@ function getReducerError() {
} }
function togglePersist() { function togglePersist() {
const state = window.store.getState(); const state = store.getState();
if (state.instances.persisted) { if (state.instances.persisted) {
Object.keys(state.instances.connections).forEach((id) => { Object.keys(state.instances.connections).forEach((id) => {
if (connections.tab[id]) return; if (connections.tab[id]) return;
window.store.dispatch({ type: REMOVE_INSTANCE, id }); store.dispatch({ type: REMOVE_INSTANCE, id });
toMonitors({ type: 'NA', id }); toMonitors({ type: 'NA', id });
}); });
} }
@ -461,34 +455,25 @@ interface OpenOptionsMessage {
readonly type: 'OPEN_OPTIONS'; readonly type: 'OPEN_OPTIONS';
} }
interface GetOptionsMessage { export type SingleMessage = OpenMessage | OpenOptionsMessage | OptionsMessage;
readonly type: 'GET_OPTIONS';
}
export type SingleMessage =
| OpenMessage
| OpenOptionsMessage
| GetOptionsMessage;
type BackgroundStoreMessage<S, A extends Action<string>> = type BackgroundStoreMessage<S, A extends Action<string>> =
| PageScriptToContentScriptMessageWithoutDisconnectOrInitInstance<S, A> | PageScriptToContentScriptMessageWithoutDisconnectOrInitInstance<S, A>
| SplitMessage | SplitMessage
| SingleMessage; | SingleMessage;
type BackgroundStoreResponse = { readonly options: Options };
// Receive messages from content scripts // Receive messages from content scripts
function messaging<S, A extends Action<string>>( function messaging<S, A extends Action<string>>(
request: BackgroundStoreMessage<S, A>, request: BackgroundStoreMessage<S, A>,
sender: chrome.runtime.MessageSender, sender: chrome.runtime.MessageSender,
sendResponse?: (response?: BackgroundStoreResponse) => void,
) { ) {
let tabId = getId(sender); let tabId = getId(sender);
if (!tabId) return; if (!tabId) return;
if (sender.frameId) tabId = `${tabId}-${sender.frameId}`; if (sender.frameId) tabId = `${tabId}-${sender.frameId}`;
if (request.type === 'STOP') { if (request.type === 'STOP') {
if (!Object.keys(window.store.getState().instances.connections).length) { if (!Object.keys(store.getState().instances.connections).length) {
window.store.dispatch({ type: DISCONNECTED }); store.dispatch({ type: DISCONNECTED });
} }
return; return;
} }
@ -496,10 +481,8 @@ function messaging<S, A extends Action<string>>(
chrome.runtime.openOptionsPage(); chrome.runtime.openOptionsPage();
return; return;
} }
if (request.type === 'GET_OPTIONS') { if (request.type === 'OPTIONS') {
window.syncOptions.get((options) => { toAllTabs({ type: 'OPTIONS', options: request.options });
sendResponse!({ options });
});
return; return;
} }
if (request.type === 'GET_REPORT') { if (request.type === 'GET_REPORT') {
@ -507,12 +490,8 @@ function messaging<S, A extends Action<string>>(
return; return;
} }
if (request.type === 'OPEN') { if (request.type === 'OPEN') {
let position: DevToolsPosition = 'devtools-left'; let position: DevToolsPosition = 'devtools-window';
if ( if (['remote', 'window'].includes(request.position)) {
['remote', 'panel', 'left', 'right', 'bottom'].indexOf(
request.position,
) !== -1
) {
position = ('devtools-' + request.position) as DevToolsPosition; position = ('devtools-' + request.position) as DevToolsPosition;
} }
openDevToolsWindow(position); openDevToolsWindow(position);
@ -560,7 +539,7 @@ function messaging<S, A extends Action<string>>(
if (request.instanceId) { if (request.instanceId) {
action.request.instanceId = instanceId; action.request.instanceId = instanceId;
} }
window.store.dispatch(action); store.dispatch(action);
if (request.type === 'EXPORT') { if (request.type === 'EXPORT') {
toMonitors(action, tabId, true); toMonitors(action, tabId, true);
@ -580,8 +559,8 @@ function disconnect(
if (p) p.onDisconnect.removeListener(disconnectListener); if (p) p.onDisconnect.removeListener(disconnectListener);
delete connections[type][id]; delete connections[type][id];
if (type === 'tab') { if (type === 'tab') {
if (!window.store.getState().instances.persisted) { if (!store.getState().instances.persisted) {
window.store.dispatch({ type: REMOVE_INSTANCE, id }); store.dispatch({ type: REMOVE_INSTANCE, id });
toMonitors({ type: 'NA', id }); toMonitors({ type: 'NA', id });
} }
} else { } else {
@ -595,21 +574,22 @@ function onConnect<S, A extends Action<string>>(port: chrome.runtime.Port) {
let id: number | string; let id: number | string;
let listener; let listener;
window.store.dispatch({ type: CONNECTED, port }); store.dispatch({ type: CONNECTED, port });
if (port.name === 'tab') { if (port.name === 'tab') {
id = getId(port.sender!); id = getId(port.sender!);
if (port.sender!.frameId) id = `${id}-${port.sender!.frameId}`; if (port.sender!.frameId) id = `${id}-${port.sender!.frameId}`;
connections.tab[id] = port; connections.tab[id] = port;
listener = (msg: ContentScriptToBackgroundMessage<S, A>) => { listener = (msg: ContentScriptToBackgroundMessage<S, A> | 'heartbeat') => {
if (msg === 'heartbeat') return;
if (msg.name === 'INIT_INSTANCE') { if (msg.name === 'INIT_INSTANCE') {
if (typeof id === 'number') { if (typeof id === 'number') {
chrome.pageAction.show(id); chrome.action.enable(id);
chrome.pageAction.setIcon({ tabId: id, path: 'img/logo/38x38.png' }); chrome.action.setIcon({ tabId: id, path: 'img/logo/38x38.png' });
} }
if (isMonitored) port.postMessage({ type: 'START' }); port.postMessage({ type: 'START' });
const state = window.store.getState(); const state = store.getState();
if (state.instances.persisted) { if (state.instances.persisted) {
const instanceId = `${id}/${msg.instanceId}`; const instanceId = `${id}/${msg.instanceId}`;
const persistedState = state.instances.states[instanceId]; const persistedState = state.instances.states[instanceId];
@ -636,6 +616,11 @@ function onConnect<S, A extends Action<string>>(port: chrome.runtime.Port) {
id = getId(port.sender!, port.name); id = getId(port.sender!, port.name);
connections.monitor[id] = port; connections.monitor[id] = port;
monitorInstances(true); monitorInstances(true);
listener = (msg: BackgroundAction | 'heartbeat') => {
if (msg === 'heartbeat') return;
store.dispatch(msg);
};
port.onMessage.addListener(listener);
monitors++; monitors++;
port.onDisconnect.addListener(disconnect('monitor', id)); port.onDisconnect.addListener(disconnect('monitor', id));
} else { } else {
@ -644,8 +629,9 @@ function onConnect<S, A extends Action<string>>(port: chrome.runtime.Port) {
connections.panel[id] = port; connections.panel[id] = port;
monitorInstances(true, port.name); monitorInstances(true, port.name);
monitors++; monitors++;
listener = (msg: BackgroundAction) => { listener = (msg: BackgroundAction | 'heartbeat') => {
window.store.dispatch(msg); if (msg === 'heartbeat') return;
store.dispatch(msg);
}; };
port.onMessage.addListener(listener); port.onMessage.addListener(listener);
port.onDisconnect.addListener(disconnect('panel', id, listener)); port.onDisconnect.addListener(disconnect('panel', id, listener));
@ -659,17 +645,9 @@ chrome.runtime.onMessageExternal.addListener(messaging);
chrome.notifications.onClicked.addListener((id) => { chrome.notifications.onClicked.addListener((id) => {
chrome.notifications.clear(id); chrome.notifications.clear(id);
openDevToolsWindow('devtools-right'); openDevToolsWindow('devtools-window');
}); });
declare global {
interface Window {
syncOptions: SyncOptions;
}
}
window.syncOptions = syncOptions(toAllTabs); // Expose to the options page
const api: Middleware<{}, BackgroundState, Dispatch<BackgroundAction>> = const api: Middleware<{}, BackgroundState, Dispatch<BackgroundAction>> =
(store) => (next) => (untypedAction) => { (store) => (next) => (untypedAction) => {
const action = untypedAction as BackgroundAction; const action = untypedAction as BackgroundAction;

View File

@ -1,8 +1,10 @@
import '../chromeApiMock'; import '../chromeApiMock';
import { import {
injectOptions, getOptions,
getOptionsFromBg,
isAllowed, isAllowed,
Options,
prefetchOptions,
prepareOptionsForPage,
} from '../options/syncOptions'; } from '../options/syncOptions';
import type { TabMessage } from '../background/store/apiMiddleware'; import type { TabMessage } from '../background/store/apiMiddleware';
import type { import type {
@ -84,6 +86,13 @@ interface UpdateAction {
readonly source: typeof source; readonly source: typeof source;
} }
interface OptionsAction {
readonly type: 'OPTIONS';
readonly options: Options;
readonly id: undefined;
readonly source: typeof source;
}
export type ContentScriptToPageScriptMessage = export type ContentScriptToPageScriptMessage =
| StartAction | StartAction
| StopAction | StopAction
@ -91,7 +100,8 @@ export type ContentScriptToPageScriptMessage =
| ImportAction | ImportAction
| ActionAction | ActionAction
| ExportAction | ExportAction
| UpdateAction; | UpdateAction
| OptionsAction;
interface ImportStatePayload<S, A extends Action<string>> { interface ImportStatePayload<S, A extends Action<string>> {
readonly type: 'IMPORT_STATE'; readonly type: 'IMPORT_STATE';
@ -112,6 +122,7 @@ export type ListenerMessage<S, A extends Action<string>> =
| ActionAction | ActionAction
| ExportAction | ExportAction
| UpdateAction | UpdateAction
| OptionsAction
| ImportStateDispatchAction<S, A>; | ImportStateDispatchAction<S, A>;
function postToPageScript(message: ContentScriptToPageScriptMessage) { function postToPageScript(message: ContentScriptToPageScriptMessage) {
@ -156,8 +167,13 @@ function connect() {
source, source,
}); });
} }
} else if ('options' in message) { } else if (message.type === 'OPTIONS') {
injectOptions(message.options); postToPageScript({
type: message.type,
options: prepareOptionsForPage(message.options),
id: undefined,
source,
});
} else { } else {
postToPageScript({ postToPageScript({
type: message.type, type: message.type,
@ -289,7 +305,14 @@ function send<S, A extends Action<string>>(
) { ) {
if (!connected) connect(); if (!connected) connect();
if (message.type === 'INIT_INSTANCE') { if (message.type === 'INIT_INSTANCE') {
getOptionsFromBg(); getOptions((options) => {
postToPageScript({
type: 'OPTIONS',
options: prepareOptionsForPage(options),
id: undefined,
source,
});
});
postToBackground({ name: 'INIT_INSTANCE', instanceId: message.instanceId }); postToBackground({ name: 'INIT_INSTANCE', instanceId: message.instanceId });
} else { } else {
postToBackground({ name: 'RELAY', message }); postToBackground({ name: 'RELAY', message });
@ -317,4 +340,10 @@ function handleMessages<S, A extends Action<string>>(
tryCatch(send, message); tryCatch(send, message);
} }
prefetchOptions();
window.addEventListener('message', handleMessages, false); window.addEventListener('message', handleMessages, false);
setInterval(() => {
bg?.postMessage('heartbeat');
}, 15000);

View File

@ -5,12 +5,13 @@ html
meta(charset='UTF-8') meta(charset='UTF-8')
title Redux DevTools title Redux DevTools
include ../style.pug include ../style.pug
style.
body {
min-height: 100px;
}
body body
#root #root
div(style='display: flex; justify-content: center; align-items: center')
img(
src='/img/loading.svg',
height=300, width=350,
)
link(href='/devpanel.bundle.css', rel='stylesheet') link(href='/devpanel.bundle.css', rel='stylesheet')
script(src='/devpanel.bundle.js') script(src='/devpanel.bundle.js')

View File

@ -6,6 +6,7 @@ import { Persistor } from 'redux-persist';
import { import {
REMOVE_INSTANCE, REMOVE_INSTANCE,
StoreAction, StoreAction,
StoreState,
UPDATE_STATE, UPDATE_STATE,
} from '@redux-devtools/app'; } from '@redux-devtools/app';
import App from '../app/App'; import App from '../app/App';
@ -18,19 +19,19 @@ import {
SplitUpdateStateRequest, SplitUpdateStateRequest,
UpdateStateRequest, UpdateStateRequest,
} from '../background/store/apiMiddleware'; } from '../background/store/apiMiddleware';
import type { StoreStateWithoutSocket } from './store/panelReducer';
import { PersistGate } from 'redux-persist/integration/react'; import { PersistGate } from 'redux-persist/integration/react';
const position = location.hash; const position = location.hash;
const messageStyle: CSSProperties = { const messageStyle: CSSProperties = {
padding: '20px', paddingTop: '20px',
width: '100%', width: '100%',
textAlign: 'center', textAlign: 'center',
boxSizing: 'border-box',
}; };
let rendered: boolean | undefined; let rendered: boolean | undefined;
let currentRoot: Root | undefined; let currentRoot: Root | undefined;
let store: Store<StoreStateWithoutSocket, StoreAction> | undefined; let store: Store<StoreState, StoreAction> | undefined;
let persistor: Persistor | undefined; let persistor: Persistor | undefined;
let bgConnection: chrome.runtime.Port; let bgConnection: chrome.runtime.Port;
let naTimeout: NodeJS.Timeout; let naTimeout: NodeJS.Timeout;
@ -72,7 +73,12 @@ function renderNA() {
. .
</div> </div>
); );
if (isChrome) { if (
isChrome &&
chrome &&
chrome.devtools &&
chrome.devtools.inspectedWindow
) {
chrome.devtools.inspectedWindow.getResources((resources) => { chrome.devtools.inspectedWindow.getResources((resources) => {
if (resources[0].url.substr(0, 4) === 'file') { if (resources[0].url.substr(0, 4) === 'file') {
message = ( message = (
@ -101,17 +107,26 @@ function renderNA() {
let splitMessage: SplitUpdateStateRequest<unknown, Action<string>>; let splitMessage: SplitUpdateStateRequest<unknown, Action<string>>;
function init(id: number) { function init() {
renderNA(); renderNA();
bgConnection = chrome.runtime.connect({
name: id ? id.toString() : undefined, let name = 'monitor';
}); if (chrome && chrome.devtools && chrome.devtools.inspectedWindow) {
name += chrome.devtools.inspectedWindow.tabId;
}
bgConnection = chrome.runtime.connect({ name });
setInterval(() => {
bgConnection.postMessage('heartbeat');
}, 15000);
bgConnection.onMessage.addListener( bgConnection.onMessage.addListener(
<S, A extends Action<string>>( <S, A extends Action<string>>(
message: PanelMessageWithSplitAction<S, A>, message: PanelMessageWithSplitAction<S, A>,
) => { ) => {
if (message.type === 'NA') { if (message.type === 'NA') {
if (message.id === id) renderNA(); // TODO Double-check this now that the name is different
if (message.id === name) renderNA();
else store!.dispatch({ type: REMOVE_INSTANCE, id: message.id }); else store!.dispatch({ type: REMOVE_INSTANCE, id: message.id });
} else { } else {
if (!rendered) renderDevTools(); if (!rendered) renderDevTools();
@ -157,4 +172,7 @@ function init(id: number) {
); );
} }
init(chrome.devtools.inspectedWindow.tabId); if (position === '#popup') document.body.style.minWidth = '760px';
if (position !== '#popup') document.body.style.minHeight = '100%';
init();

View File

@ -1,45 +1,29 @@
import { combineReducers, Reducer } from 'redux'; import { combineReducers, Reducer } from 'redux';
import { import {
connection, connection,
ConnectionState,
instances, instances,
InstancesState,
monitor, monitor,
MonitorState,
notification, notification,
NotificationState,
reports, reports,
ReportsState,
section, section,
SectionState, socket,
StateTreeSettings,
stateTreeSettings, stateTreeSettings,
StoreAction, StoreAction,
StoreState,
theme, theme,
ThemeState,
} from '@redux-devtools/app'; } from '@redux-devtools/app';
export interface StoreStateWithoutSocket {
readonly section: SectionState;
readonly theme: ThemeState;
readonly connection: ConnectionState;
readonly monitor: MonitorState;
readonly instances: InstancesState;
readonly reports: ReportsState;
readonly notification: NotificationState;
readonly stateTreeSettings: StateTreeSettings;
}
const rootReducer: Reducer< const rootReducer: Reducer<
StoreStateWithoutSocket, StoreState,
StoreAction, StoreAction,
Partial<StoreStateWithoutSocket> Partial<StoreState>
> = combineReducers({ > = combineReducers({
instances, instances,
monitor, monitor,
reports, reports,
notification, notification,
section, section,
socket,
theme, theme,
connection, connection,
stateTreeSettings, stateTreeSettings,

View File

@ -1,9 +1,13 @@
import { createStore, applyMiddleware, Reducer, Store } from 'redux'; import { createStore, applyMiddleware, Reducer, Store } from 'redux';
import localForage from 'localforage'; import localForage from 'localforage';
import { persistReducer, persistStore } from 'redux-persist'; import { persistReducer, persistStore } from 'redux-persist';
import { exportStateMiddleware, StoreAction } from '@redux-devtools/app'; import {
exportStateMiddleware,
StoreAction,
StoreState,
} from '@redux-devtools/app';
import panelDispatcher from './panelSyncMiddleware'; import panelDispatcher from './panelSyncMiddleware';
import rootReducer, { StoreStateWithoutSocket } from './panelReducer'; import rootReducer from './panelReducer';
const persistConfig = { const persistConfig = {
key: 'redux-devtools', key: 'redux-devtools',
@ -11,8 +15,10 @@ const persistConfig = {
storage: localForage, storage: localForage,
}; };
const persistedReducer: Reducer<StoreStateWithoutSocket, StoreAction> = const persistedReducer: Reducer<StoreState, StoreAction> = persistReducer(
persistReducer(persistConfig, rootReducer) as any; persistConfig,
rootReducer,
) as any;
export default function configureStore( export default function configureStore(
position: string, position: string,

View File

@ -7,23 +7,51 @@ import {
TOGGLE_PERSIST, TOGGLE_PERSIST,
UPDATE_STATE, UPDATE_STATE,
} from '@redux-devtools/app'; } from '@redux-devtools/app';
import { Dispatch, Middleware } from 'redux'; import { Dispatch, Middleware, MiddlewareAPI } from 'redux';
function selectInstance(
tabId: number,
store: MiddlewareAPI<Dispatch<StoreAction>, StoreState>,
next: (action: unknown) => unknown,
) {
const instances = store.getState().instances;
if (instances.current === 'default') return;
const connections = instances.connections[tabId];
if (connections && connections.length === 1) {
next({ type: SELECT_INSTANCE, selected: connections[0] });
}
}
function getCurrentTabId(next: (tabId: number) => void) {
chrome.tabs.query(
{
active: true,
lastFocusedWindow: true,
},
(tabs) => {
const tab = tabs[0];
if (!tab) return;
next(tab.id!);
},
);
}
function panelDispatcher( function panelDispatcher(
bgConnection: chrome.runtime.Port, bgConnection: chrome.runtime.Port,
): Middleware<{}, StoreState, Dispatch<StoreAction>> { ): Middleware<{}, StoreState, Dispatch<StoreAction>> {
let autoselected = false; let autoselected = false;
const tabId = chrome.devtools.inspectedWindow.tabId;
return (store) => (next) => (untypedAction) => { return (store) => (next) => (untypedAction) => {
const action = untypedAction as StoreAction; const action = untypedAction as StoreAction;
const result = next(action); const result = next(action);
if (!autoselected && action.type === UPDATE_STATE && tabId) { if (!autoselected && action.type === UPDATE_STATE) {
autoselected = true; autoselected = true;
const connections = store.getState().instances.connections[tabId];
if (connections && connections.length === 1) { if (chrome.devtools && chrome.devtools.inspectedWindow) {
next({ type: SELECT_INSTANCE, selected: connections[0] }); selectInstance(chrome.devtools.inspectedWindow.tabId, store, next);
} else {
getCurrentTabId((tabId) => selectInstance(tabId, store, next));
} }
} }
if (action.type === LIFTED_ACTION || action.type === TOGGLE_PERSIST) { if (action.type === LIFTED_ACTION || action.type === TOGGLE_PERSIST) {

View File

@ -1,17 +1,6 @@
function createPanel(url: string) { chrome.devtools.panels.create(
chrome.devtools.panels.create( 'Redux',
'Redux', 'img/logo/scalable.png',
'img/logo/scalable.png', 'devpanel.html',
url, () => {},
function () {}, );
);
}
if (chrome.runtime.getBackgroundPage) {
// Check if the background page's object is accessible (not in incognito)
chrome.runtime.getBackgroundPage((background) => {
createPanel(background ? 'window.html' : 'devpanel.html');
});
} else {
createPanel('devpanel.html');
}

View File

@ -2,22 +2,25 @@ import '../chromeApiMock';
import React from 'react'; import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import OptionsComponent from './Options'; import OptionsComponent from './Options';
import { Options } from './syncOptions'; import {
getOptions,
Options,
OptionsMessage,
saveOption,
subscribeToOptions,
} from './syncOptions';
chrome.runtime.getBackgroundPage((background) => { subscribeToOptions((options) => {
const syncOptions = background!.syncOptions; const message: OptionsMessage = { type: 'OPTIONS', options };
chrome.runtime.sendMessage(message);
const saveOption = <K extends keyof Options>(name: K, value: Options[K]) => { });
syncOptions.save(name, value);
}; const renderOptions = (options: Options) => {
const root = createRoot(document.getElementById('root')!);
const renderOptions = (options: Options) => { root.render(<OptionsComponent options={options} saveOption={saveOption} />);
const root = createRoot(document.getElementById('root')!); };
root.render(<OptionsComponent options={options} saveOption={saveOption} />);
}; subscribeToOptions(renderOptions);
getOptions((options) => {
syncOptions.subscribe(renderOptions); renderOptions(options);
syncOptions.get((options) => {
renderOptions(options);
});
}); });

View File

@ -38,21 +38,22 @@ let options: Options | undefined;
let subscribers: ((options: Options) => void)[] = []; let subscribers: ((options: Options) => void)[] = [];
export interface OptionsMessage { export interface OptionsMessage {
readonly type: 'OPTIONS';
readonly options: Options; readonly options: Options;
} }
type ToAllTabs = (msg: OptionsMessage) => void; export const saveOption = <K extends keyof Options>(
key: K,
const save = value: Options[K],
(toAllTabs: ToAllTabs | undefined) => ) => {
<K extends keyof Options>(key: K, value: Options[K]) => { let obj: { [K1 in keyof Options]?: Options[K1] } = {};
let obj: { [K1 in keyof Options]?: Options[K1] } = {}; obj[key] = value;
obj[key] = value; chrome.storage.sync.set(obj);
chrome.storage.sync.set(obj); options![key] = value;
options![key] = value; for (const subscriber of subscribers) {
toAllTabs!({ options: options! }); subscriber(options!);
subscribers.forEach((s) => s(options!)); }
}; };
const migrateOldOptions = (oldOptions: OldOrNewOptions): Options => ({ const migrateOldOptions = (oldOptions: OldOrNewOptions): Options => ({
...oldOptions, ...oldOptions,
@ -71,7 +72,7 @@ const migrateOldOptions = (oldOptions: OldOrNewOptions): Options => ({
: oldOptions.filter, : oldOptions.filter,
}); });
const get = (callback: (options: Options) => void) => { export const getOptions = (callback: (options: Options) => void) => {
if (options) callback(options); if (options) callback(options);
else { else {
chrome.storage.sync.get( chrome.storage.sync.get(
@ -98,67 +99,29 @@ const get = (callback: (options: Options) => void) => {
} }
}; };
const subscribe = (callback: (options: Options) => void) => { export const prefetchOptions = () => getOptions(() => {});
export const subscribeToOptions = (callback: (options: Options) => void) => {
subscribers = subscribers.concat(callback); subscribers = subscribers.concat(callback);
}; };
const toReg = (str: string) => const toReg = (str: string) =>
str !== '' ? str.split('\n').filter(Boolean).join('|') : null; str !== '' ? str.split('\n').filter(Boolean).join('|') : null;
export const injectOptions = (newOptions: Options) => { export const prepareOptionsForPage = (options: Options): Options => ({
if (!newOptions) return; ...options,
allowlist:
options = { options.filter !== FilterState.DO_NOT_FILTER
...newOptions, ? toReg(options.allowlist)!
allowlist: : options.allowlist,
newOptions.filter !== FilterState.DO_NOT_FILTER denylist:
? toReg(newOptions.allowlist)! options.filter !== FilterState.DO_NOT_FILTER
: newOptions.allowlist, ? toReg(options.denylist)!
denylist: : options.denylist,
newOptions.filter !== FilterState.DO_NOT_FILTER });
? toReg(newOptions.denylist)!
: newOptions.denylist,
};
let s = document.createElement('script');
s.type = 'text/javascript';
s.appendChild(
document.createTextNode(
'window.devToolsOptions = Object.assign(window.devToolsOptions||{},' +
JSON.stringify(options) +
');',
),
);
(document.head || document.documentElement).appendChild(s);
s.parentNode!.removeChild(s);
};
export const getOptionsFromBg = () => {
/* chrome.runtime.sendMessage({ type: 'GET_OPTIONS' }, response => {
if (response && response.options) injectOptions(response.options);
});
*/
get((newOptions) => {
injectOptions(newOptions);
}); // Legacy
};
export const isAllowed = (localOptions = options) => export const isAllowed = (localOptions = options) =>
!localOptions || !localOptions ||
localOptions.inject || localOptions.inject ||
!localOptions.urls || !localOptions.urls ||
location.href.match(toReg(localOptions.urls)!); location.href.match(toReg(localOptions.urls)!);
export interface SyncOptions {
readonly save: <K extends keyof Options>(key: K, value: Options[K]) => void;
readonly get: (callback: (options: Options) => void) => void;
readonly subscribe: (callback: (options: Options) => void) => void;
}
export default function syncOptions(toAllTabs?: ToAllTabs): SyncOptions {
if (toAllTabs && !options) get(() => {}); // Initialize
return {
save: save(toAllTabs),
get: get,
subscribe: subscribe,
};
}

View File

@ -1,7 +1,7 @@
import { Action } from 'redux'; import { Action } from 'redux';
import type { PageScriptToContentScriptMessage } from './index'; import type { PageScriptToContentScriptMessage } from './index';
export type Position = 'left' | 'right' | 'bottom' | 'panel' | 'remote'; export type Position = 'window' | 'remote';
function post<S, A extends Action<string>>( function post<S, A extends Action<string>>(
message: PageScriptToContentScriptMessage<S, A>, message: PageScriptToContentScriptMessage<S, A>,
@ -13,6 +13,6 @@ export default function openWindow(position?: Position) {
post({ post({
source: '@devtools-page', source: '@devtools-page',
type: 'OPEN', type: 'OPEN',
position: position || 'right', position: position ?? 'window',
}); });
} }

View File

@ -432,6 +432,13 @@ function __REDUX_DEVTOOLS_EXTENSION__<S, A extends Action<string>>(
serializeAction, serializeAction,
); );
} }
return;
case 'OPTIONS':
window.devToolsOptions = Object.assign(
window.devToolsOptions || {},
message.options,
);
return;
} }
} }

View File

@ -1,19 +0,0 @@
// @ts-ignore
import script from '../dist/page.bundle.js';
let s = document.createElement('script');
s.type = 'text/javascript';
if (process.env.NODE_ENV === 'production') {
s.appendChild(document.createTextNode(script));
(document.head || document.documentElement).appendChild(s);
s.parentNode!.removeChild(s);
} else {
s.src = chrome.extension.getURL('page.bundle.js');
s.onload = function () {
(this as HTMLScriptElement).parentNode!.removeChild(
this as HTMLScriptElement,
);
};
(document.head || document.documentElement).appendChild(s);
}

View File

@ -1,37 +0,0 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { PersistGate } from 'redux-persist/integration/react';
import { UPDATE_STATE } from '@redux-devtools/app';
import App from '../app/App';
import configureStore from './store/windowStore';
import type { MonitorMessage } from '../background/store/apiMiddleware';
const position = location.hash;
chrome.runtime.getBackgroundPage((window) => {
const { store } = window!;
const { store: localStore, persistor } = configureStore(store, position);
let name = 'monitor';
if (chrome && chrome.devtools && chrome.devtools.inspectedWindow) {
name += chrome.devtools.inspectedWindow.tabId;
}
const bg = chrome.runtime.connect({ name });
const update = (action?: MonitorMessage) => {
localStore.dispatch(action || { type: UPDATE_STATE });
};
bg.onMessage.addListener(update);
update();
const root = createRoot(document.getElementById('root')!);
root.render(
<Provider store={localStore}>
<PersistGate loading={null} persistor={persistor}>
<App position={position} />
</PersistGate>
</Provider>,
);
});
if (position === '#popup') document.body.style.minWidth = '760px';
if (position !== '#popup') document.body.style.minHeight = '100%';

View File

@ -1,51 +0,0 @@
import { Dispatch, Middleware, MiddlewareAPI } from 'redux';
import {
SELECT_INSTANCE,
StoreAction,
StoreState,
UPDATE_STATE,
} from '@redux-devtools/app';
function selectInstance(
tabId: number,
store: MiddlewareAPI<Dispatch<StoreAction>, StoreState>,
next: (action: unknown) => unknown,
) {
const instances = store.getState().instances;
if (instances.current === 'default') return;
const connections = instances.connections[tabId];
if (connections && connections.length === 1) {
next({ type: SELECT_INSTANCE, selected: connections[0] });
}
}
function getCurrentTabId(next: (tabId: number) => void) {
chrome.tabs.query(
{
active: true,
lastFocusedWindow: true,
},
(tabs) => {
const tab = tabs[0];
if (!tab) return;
next(tab.id!);
},
);
}
const popupSelector: Middleware<{}, StoreState, Dispatch<StoreAction>> =
(store) => (next) => (untypedAction) => {
const action = untypedAction as StoreAction;
const result = next(action);
if (action.type === UPDATE_STATE) {
if (chrome.devtools && chrome.devtools.inspectedWindow) {
selectInstance(chrome.devtools.inspectedWindow.tabId, store, next);
} else {
getCurrentTabId((tabId) => selectInstance(tabId, store, next));
}
}
return result;
};
export default popupSelector;

View File

@ -1,34 +0,0 @@
import {
instancesInitialState,
dispatchAction,
UPDATE_STATE,
SELECT_INSTANCE,
LIFTED_ACTION,
SET_PERSIST,
} from '@redux-devtools/app';
import type {
ExpandedUpdateStateAction,
WindowStoreAction,
} from './windowStore';
export default function instances(
state = instancesInitialState,
action: WindowStoreAction,
) {
switch (action.type) {
case UPDATE_STATE:
return {
...(action as ExpandedUpdateStateAction).instances,
selected: state.selected,
};
case LIFTED_ACTION:
if (action.message === 'DISPATCH') return dispatchAction(state, action);
return state;
case SELECT_INSTANCE:
return { ...state, selected: action.selected };
case SET_PERSIST:
return { ...state, persisted: action.payload };
default:
return state;
}
}

View File

@ -1,32 +0,0 @@
import { combineReducers, Reducer } from 'redux';
import {
connection,
monitor,
notification,
reports,
section,
socket,
theme,
stateTreeSettings,
StoreState,
} from '@redux-devtools/app';
import instances from './instancesReducer';
import type { WindowStoreAction } from './windowStore';
const rootReducer: Reducer<
StoreState,
WindowStoreAction,
Partial<StoreState>
> = combineReducers({
instances,
monitor,
socket,
reports,
notification,
section,
theme,
connection,
stateTreeSettings,
}) as any;
export default rootReducer;

View File

@ -1,81 +0,0 @@
import {
createStore,
compose,
applyMiddleware,
Store,
StoreEnhancer,
Reducer,
} from 'redux';
import localForage from 'localforage';
import { persistReducer, persistStore } from 'redux-persist';
import {
api,
CONNECT_REQUEST,
exportStateMiddleware,
InstancesState,
StoreActionWithoutUpdateState,
StoreState,
UpdateStateAction,
} from '@redux-devtools/app';
import syncStores from './windowSyncMiddleware';
import instanceSelector from './instanceSelectorMiddleware';
import rootReducer from './windowReducer';
import type { BackgroundState } from '../../background/store/backgroundReducer';
import type { BackgroundAction } from '../../background/store/backgroundStore';
import type {
EmptyUpdateStateAction,
NAAction,
} from '../../background/store/apiMiddleware';
export interface ExpandedUpdateStateAction extends UpdateStateAction {
readonly instances: InstancesState;
}
export type WindowStoreAction =
| StoreActionWithoutUpdateState
| ExpandedUpdateStateAction
| NAAction
| EmptyUpdateStateAction;
const persistConfig = {
key: 'redux-devtools',
blacklist: ['instances', 'socket'],
storage: localForage,
};
const persistedReducer: Reducer<StoreState, WindowStoreAction> = persistReducer(
persistConfig,
rootReducer,
) as any;
export default function configureStore(
baseStore: Store<BackgroundState, BackgroundAction>,
position: string,
) {
let enhancer: StoreEnhancer;
const middlewares = [exportStateMiddleware, api, syncStores(baseStore)];
if (!position || position === '#popup') {
// select current tab instance for devPanel and pageAction
middlewares.push(instanceSelector);
}
if (process.env.NODE_ENV === 'production') {
enhancer = applyMiddleware(...middlewares);
} else {
enhancer = compose(
applyMiddleware(...middlewares),
window.__REDUX_DEVTOOLS_EXTENSION__
? window.__REDUX_DEVTOOLS_EXTENSION__()
: (noop: unknown) => noop,
);
}
const store = createStore(persistedReducer, enhancer);
const persistor = persistStore(store as Store, null, () => {
if (store.getState().connection.type !== 'disabled') {
store.dispatch({
type: CONNECT_REQUEST,
});
}
});
return { store, persistor };
}

View File

@ -1,37 +0,0 @@
import {
getActiveInstance,
LIFTED_ACTION,
StoreAction,
StoreState,
TOGGLE_PERSIST,
UPDATE_STATE,
} from '@redux-devtools/app';
import { Dispatch, Middleware, Store } from 'redux';
import type { BackgroundState } from '../../background/store/backgroundReducer';
import type { BackgroundAction } from '../../background/store/backgroundStore';
const syncStores =
(
baseStore: Store<BackgroundState, BackgroundAction>,
): Middleware<{}, StoreState, Dispatch<StoreAction>> =>
(store) =>
(next) =>
(untypedAction) => {
const action = untypedAction as StoreAction;
if (action.type === UPDATE_STATE) {
return next({
...action,
instances: baseStore.getState().instances,
});
}
if (action.type === LIFTED_ACTION || action.type === TOGGLE_PERSIST) {
const instances = store.getState().instances;
const instanceId = getActiveInstance(instances);
const id = instances.options[instanceId].connectionId;
baseStore.dispatch({ ...action, instanceId, id } as any);
}
return next(action);
};
export default syncStores;

View File

@ -1,18 +0,0 @@
doctype html
html
head
meta(charset='UTF-8')
title Redux DevTools
include ../style.pug
body
#root
div(style='position: relative')
img(
src='/img/loading.svg',
height=300, width=350,
style='position: absolute; top: 50%; left: 50%; margin-top: -175px; margin-left: -175px;'
)
link(href='/window.bundle.css', rel='stylesheet')
script(src='/window.bundle.js')

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { render, screen, within } from '@testing-library/react'; import { render, screen, within } from '@testing-library/react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import configureStore from '../../../src/window/store/windowStore'; import configureStore from '../../../src/devpanel/store/panelStore';
import App from '../../../src/app/App'; import App from '../../../src/app/App';
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {

View File

@ -20,16 +20,7 @@ describe('API', () => {
expect(message).toEqual({ expect(message).toEqual({
source: '@devtools-page', source: '@devtools-page',
type: 'OPEN', type: 'OPEN',
position: 'right', position: 'window',
});
message = await listenMessage(() => {
window.__REDUX_DEVTOOLS_EXTENSION__.open('left');
});
expect(message).toEqual({
source: '@devtools-page',
type: 'OPEN',
position: 'left',
}); });
}); });

View File

@ -27,9 +27,9 @@ describe('Chrome extension', function () {
}); });
it("should open extension's window", async () => { it("should open extension's window", async () => {
await driver.get(`chrome-extension://${extensionId}/window.html#left`); await driver.get(`chrome-extension://${extensionId}/devpanel.html`);
const url = await driver.getCurrentUrl(); const url = await driver.getCurrentUrl();
expect(url).toBe(`chrome-extension://${extensionId}/window.html#left`); expect(url).toBe(`chrome-extension://${extensionId}/devpanel.html`);
}); });
it('should match document title', async () => { it('should match document title', async () => {
@ -37,25 +37,6 @@ describe('Chrome extension', function () {
expect(title).toBe('Redux DevTools'); expect(title).toBe('Redux DevTools');
}); });
it("should contain inspector monitor's component", async () => {
await delay(1000);
const val = await driver
.findElement(webdriver.By.xpath('//div[@data-testid="inspector"]'))
.getText();
expect(val).toBeDefined();
});
it('should contain an empty actions list', async () => {
const val = await driver
.findElement(webdriver.By.xpath('//div[@data-testid="actionListRows"]'))
.getText();
expect(val).toBe('');
});
Object.keys(switchMonitorTests).forEach((description) =>
it(description, () => switchMonitorTests[description](driver)),
);
it('should get actions list', async () => { it('should get actions list', async () => {
const url = 'https://zalmoxisus.github.io/examples/router/'; const url = 'https://zalmoxisus.github.io/examples/router/';
await driver.executeScript(`window.open('${url}')`); await driver.executeScript(`window.open('${url}')`);
@ -68,6 +49,7 @@ describe('Chrome extension', function () {
await driver.switchTo().window(tabs[0]); await driver.switchTo().window(tabs[0]);
await delay(1000);
const result = await driver.wait( const result = await driver.wait(
driver driver
.findElement(webdriver.By.xpath('//div[@data-testid="actionListRows"]')) .findElement(webdriver.By.xpath('//div[@data-testid="actionListRows"]'))
@ -80,4 +62,15 @@ describe('Chrome extension', function () {
); );
expect(result).toBeTruthy(); expect(result).toBeTruthy();
}); });
it("should contain inspector monitor's component", async () => {
const val = await driver
.findElement(webdriver.By.xpath('//div[@data-testid="inspector"]'))
.getText();
expect(val).toBeDefined();
});
Object.keys(switchMonitorTests).forEach((description) =>
it(description, () => switchMonitorTests[description](driver)),
);
}); });

View File

@ -76,7 +76,7 @@ describe('DevTools panel for Electron', function () {
expect(className).not.toMatch(/hidden/); // not hidden expect(className).not.toMatch(/hidden/); // not hidden
}); });
it('should have Redux DevTools UI on current tab', async () => { it.skip('should have Redux DevTools UI on current tab', async () => {
await driver await driver
.switchTo() .switchTo()
.frame( .frame(
@ -87,7 +87,7 @@ describe('DevTools panel for Electron', function () {
await delay(1000); await delay(1000);
}); });
it('should contain INIT action', async () => { it.skip('should contain INIT action', async () => {
const element = await driver.wait( const element = await driver.wait(
webdriver.until.elementLocated( webdriver.until.elementLocated(
webdriver.By.xpath('//div[@data-testid="actionListRows"]'), webdriver.By.xpath('//div[@data-testid="actionListRows"]'),
@ -99,7 +99,7 @@ describe('DevTools panel for Electron', function () {
expect(val).toMatch(/@@INIT/); expect(val).toMatch(/@@INIT/);
}); });
it("should contain Inspector monitor's component", async () => { it.skip("should contain Inspector monitor's component", async () => {
const val = await driver const val = await driver
.findElement(webdriver.By.xpath('//div[@data-testid="inspector"]')) .findElement(webdriver.By.xpath('//div[@data-testid="inspector"]'))
.getText(); .getText();
@ -107,7 +107,7 @@ describe('DevTools panel for Electron', function () {
}); });
Object.keys(switchMonitorTests).forEach((description) => Object.keys(switchMonitorTests).forEach((description) =>
it(description, () => switchMonitorTests[description](driver)), it.skip(description, () => switchMonitorTests[description](driver)),
); );
/* it('should be no logs in console of main window', async () => { /* it('should be no logs in console of main window', async () => {