Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions flow-typed/npm/cross-spawn_v7.x.x.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
* @format
*/

declare module 'cross-spawn' {
import * as child_process from 'child_process';

type spawn = typeof child_process.spawn & {
spawn: spawn,
sync: typeof child_process.spawnSync,
};

declare module.exports: spawn;
}
14 changes: 14 additions & 0 deletions flow-typed/npm/electron_v36.x.x.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/

// Types for Electron when required as a Node package.
declare module 'electron' {
declare module.exports: string;
}
13 changes: 13 additions & 0 deletions packages/debugger-shell/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# @react-native/debugger-shell

![npm package](https://img.shields.io/npm/v/@react-native/debugger-shell?color=brightgreen&label=npm%20package)

Experimental Electron-based shell for React Native DevTools. This package is not part of React Native's public API.

## Why Electron?

The React Native DevTools frontend is based on Chrome DevTools, which is a web app, but is not particularly portable: it's designed to run in Chromium, and Chromium only. Prior to `@react-native/debugger-shell`, we would run it in [hosted mode](https://chromium.googlesource.com/devtools/devtools-frontend/+/main/docs/get_the_code.md#running-in-hosted-mode) in an instance of Chrome or Edge.

Relying on hosted mode presents a variety of UX issues in the debugging workflow, such as the need to ask developers to install a particular browser before they can debug in React Native, and the inability to foreground/reuse existing debugger windows when relaunching the debugger for the same app. In order to address these issues effectively, we fundamentally need to leave the browser sandbox and run the debugger in a shell we can bundle with React Native, and whose behavior we can control.

Electron is a tried-and-tested framework for the *specific* task of embedding a Chromium browser in a portable, customized shell. As a rule we'll hold a high bar for performance and reliability, and we'll only add features to the shell if they are strictly necessary to complement the DevTools frontend's built-in capabilities.
41 changes: 41 additions & 0 deletions packages/debugger-shell/__tests__/electron-dependency-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/

'use strict';

const semver = require('semver');

// This test ensures that the electron dependency declared in our package.json
// is semver-satisfied by the actual version of the electron package we're using.
// While this is normally the job of a package manager like Yarn, in our case we
// may use Yarn forced resolutions that defeat versioning, so we want additional
// safety to ensure the target of the resolution is in sync with the declared dependency.
describe('Electron dependency', () => {
test('should be semver-satisfied by the actual electron version', () => {
// $FlowIssue[untyped-import] - package.json is not typed
const ourPackageJson = require('../package.json');

const declaredElectronVersion = ourPackageJson.dependencies.electron;
expect(declaredElectronVersion).toBeTruthy();

// $FlowIssue[untyped-import] - package.json is not typed
const electronPackageJson = require('electron/package.json');

const actualElectronVersion = electronPackageJson.version;
expect(actualElectronVersion).toBeTruthy();

const isSatisfied = semver.satisfies(
actualElectronVersion,
declaredElectronVersion,
);

expect(isSatisfied).toBe(true);
});
});
36 changes: 36 additions & 0 deletions packages/debugger-shell/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "@react-native/debugger-shell",
"version": "0.80.0-main",
"description": "Experimental debugger shell for React Native for use with @react-native/debugger-frontend",
"keywords": [
"react-native",
"tools"
],
"homepage": "https://github.com/facebook/react-native/tree/HEAD/packages/debugger-shell#readme",
"bugs": "https://github.com/facebook/react-native/issues",
"main": "./src/node/index.js",
"exports": {
"node": "./src/node/index.js",
"electron": "./src/electron/index.js"
},
"scripts": {
"dev": "electron src/electron"
},
"repository": {
"type": "git",
"url": "git+https://github.com/facebook/react-native.git",
"directory": "packages/debugger-shell"
},
"license": "MIT",
"engines": {
"node": ">=18",
"electron": ">=36.2.0"
},
"dependencies": {
"cross-spawn": "^7.0.6",
"electron": "36.2.0"
},
"devDependencies": {
"semver": "^7.1.3"
}
}
104 changes: 104 additions & 0 deletions packages/debugger-shell/src/electron/MainInstanceEntryPoint.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/

// $FlowFixMe[unclear-type] We have no Flow types for the Electron API.
const {BrowserWindow, app, shell} = require('electron') as any;
const util = require('util');

const windowMetadata = new WeakMap<
typeof BrowserWindow,
$ReadOnly<{
windowKey: string,
}>,
>();

function handleLaunchArgs(argv: string[]) {
const {
values: {frontendUrl, windowKey},
} = util.parseArgs({
options: {
frontendUrl: {
type: 'string',
},
windowKey: {
type: 'string',
},
},
args: argv,
});

// Find an existing window for this app and launch configuration.
const existingWindow = BrowserWindow.getAllWindows().find(window => {
const metadata = windowMetadata.get(window);
if (!metadata) {
return false;
}
return metadata.windowKey === windowKey;
});

if (existingWindow) {
// If the window is already visible, flash it.
if (existingWindow.isVisible()) {
existingWindow.flashFrame(true);
setTimeout(() => {
existingWindow.flashFrame(false);
}, 1000);
}
if (process.platform === 'darwin') {
app.focus({
steal: true,
});
}
existingWindow.focus();
return;
}

// Create the browser window.
const frontendWindow = new BrowserWindow({
width: 1200,
height: 600,
webPreferences: {
partition: 'persist:react-native-devtools',
},
});

// Open links in the default browser instead of in new Electron windows.
frontendWindow.webContents.setWindowOpenHandler(({url}) => {
shell.openExternal(url);
return {action: 'deny'};
});

frontendWindow.loadURL(frontendUrl);

windowMetadata.set(frontendWindow, {
windowKey,
});

if (process.platform === 'darwin') {
app.focus({
steal: true,
});
}
}

app.whenReady().then(() => {
handleLaunchArgs(process.argv.slice(2));

app.on(
'second-instance',
(event, electronArgv, workingDirectory, additionalData) => {
handleLaunchArgs(additionalData.argv);
},
);
});

app.on('window-all-closed', function () {
app.quit();
});
22 changes: 22 additions & 0 deletions packages/debugger-shell/src/electron/index.flow.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/

// $FlowFixMe[unclear-type] We have no Flow types for the Electron API.
const {app} = require('electron') as any;

const gotTheLock = app.requestSingleInstanceLock({
argv: process.argv.slice(2),
});

if (!gotTheLock) {
app.quit();
} else {
require('./MainInstanceEntryPoint.js');
}
19 changes: 19 additions & 0 deletions packages/debugger-shell/src/electron/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
* @format
*/

/*::
export type * from './index.flow';
*/

if (!process.env.BUILD_EXCLUDE_BABEL_REGISTER) {
require('../../../../scripts/babel-register').registerForMonorepo();
}

module.exports = require('./index.flow');
77 changes: 77 additions & 0 deletions packages/debugger-shell/src/node/index.flow.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/

const {spawn} = require('cross-spawn');

async function unstable_spawnDebuggerShellWithArgs(
args: string[],
{
mode = 'detached',
}: $ReadOnly<{
// In 'syncAndExit' mode, the current process will block until the spawned process exits, and then it will exit
// with the same exit code as the spawned process.
// In 'detached' mode, the spawned process will be detached from the current process and the current process will
// continue to run normally.
mode?: 'syncThenExit' | 'detached',
}> = {},
): Promise<void> {
// NOTE: Internally at Meta, this is aliased to a workspace that is
// API-compatible with the 'electron' package, but contains prebuilt binaries
// that do not need to be downloaded in a postinstall action.
const electronPath = require('electron');

return new Promise((resolve, reject) => {
const child = spawn(
electronPath,
[require.resolve('../electron'), ...args],
{
stdio: 'inherit',
windowsHide: true,
detached: mode === 'detached',
},
);
if (mode === 'detached') {
child.on('spawn', () => {
resolve();
});
child.on('close', (code /*: number */) => {
if (code !== 0) {
reject(
new Error(
`Failed to open debugger shell: ${electronPath} exited with code ${code}`,
),
);
}
});
child.unref();
} else if (mode === 'syncThenExit') {
child.on('close', function (code, signal) {
if (code === null) {
console.error(electronPath, 'exited with signal', signal);
process.exit(1);
}
process.exit(code);
});

const handleTerminationSignal = function (signal: string) {
process.on(signal, function signalHandler() {
if (!child.killed) {
child.kill(signal);
}
});
};

handleTerminationSignal('SIGINT');
handleTerminationSignal('SIGTERM');
}
});
}

export {unstable_spawnDebuggerShellWithArgs};
19 changes: 19 additions & 0 deletions packages/debugger-shell/src/node/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
* @format
*/

/*::
export type * from './index.flow';
*/

if (!process.env.BUILD_EXCLUDE_BABEL_REGISTER) {
require('../../../../scripts/babel-register').registerForMonorepo();
}

export * from './index.flow';
Loading
Loading