/** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @format */ import ReactDevToolsStandalone from 'react-devtools-core/standalone'; import { Layout, usePlugin, DevicePluginClient, createState, useValue, theme, sleep, Toolbar, } from 'flipper-plugin'; import React, {createRef, useEffect} from 'react'; import getPort from 'get-port'; import {Alert, Button, Switch} from 'antd'; import child_process from 'child_process'; import fs from 'fs'; import path from 'path'; const DEV_TOOLS_NODE_ID = 'reactdevtools-out-of-react-node'; interface MetroDevice { ws?: WebSocket; sendCommand(command: string, params?: any): void; } function findGlobalDevTools(): Promise { return new Promise((resolve) => { child_process.exec('npm root -g', (error, basePath) => { if (error) { console.warn( 'Failed to find globally installed React DevTools: ' + error, ); resolve(undefined); } else { const devToolsPath = path.join( basePath.trim(), 'react-devtools', 'node_modules', 'react-devtools-core', ); fs.stat(devToolsPath, (err, stats) => { resolve(!err && stats ? devToolsPath : undefined); }); } }); }); } function createDevToolsNode(): HTMLElement { const div = document.createElement('div'); div.id = DEV_TOOLS_NODE_ID; div.style.display = 'none'; div.style.width = '100%'; div.style.height = '100%'; div.style.flex = '1 1 0%'; div.style.justifyContent = 'center'; div.style.alignItems = 'stretch'; document.body && document.body.appendChild(div); return div; } function findDevToolsNode(): HTMLElement | null { return document.querySelector('#' + DEV_TOOLS_NODE_ID); } function attachDevTools(target: Element | Text, devToolsNode: HTMLElement) { target.appendChild(devToolsNode); devToolsNode.style.display = 'flex'; } function detachDevTools(devToolsNode: HTMLElement) { devToolsNode.style.display = 'none'; document.body && document.body.appendChild(devToolsNode); } const CONNECTED = 'DevTools connected'; const SUPPORTED_OCULUS_DEVICE_TYPES = ['quest', 'go', 'pacific']; enum ConnectionStatus { Initializing = 'Initializing...', WaitingForReload = 'Waiting for connection from device...', Connected = 'Connected', Error = 'Error', } export function devicePlugin(client: DevicePluginClient) { const metroDevice: MetroDevice = client.device.realDevice; if (!metroDevice.sendCommand || !('ws' in metroDevice)) { throw new Error('Invalid metroDevice'); } const statusMessage = createState('initializing'); const connectionStatus = createState( ConnectionStatus.Initializing, ); const globalDevToolsPath = createState(); const useGlobalDevTools = createState(false); // TODO: store in local storage T69989583 let devToolsInstance: typeof ReactDevToolsStandalone = ReactDevToolsStandalone; let startResult: {close(): void} | undefined = undefined; const containerRef = createRef(); let pollHandle: NodeJS.Timeout | undefined = undefined; let isMounted = false; async function toggleUseGlobalDevTools() { if (!globalDevToolsPath.get()) { return; } useGlobalDevTools.update((v) => !v); if (useGlobalDevTools.get()) { console.log('Loading ' + globalDevToolsPath.get()); devToolsInstance = global.electronRequire( globalDevToolsPath.get()!, ).default; } else { devToolsInstance = ReactDevToolsStandalone; } startResult?.close(); stopDevtools(); findDevToolsNode()!.remove(); await bootDevTools(); } async function bootDevTools() { isMounted = true; let devToolsNode = findDevToolsNode(); if (!devToolsNode) { devToolsNode = createDevToolsNode(); } attachDevTools(containerRef.current!, devToolsNode); initializeDevTools(devToolsNode); setStatus( ConnectionStatus.Initializing, 'DevTools have been initialized, waiting for connection...', ); await sleep(5); // give node time to move if (devtoolsHaveStarted()) { setStatus(ConnectionStatus.Connected, CONNECTED); } else { startPollForConnection(); } } function setStatus(cs: ConnectionStatus, status: string) { connectionStatus.set(cs); if (!isMounted) { return; } if (status.startsWith('The server is listening on')) { statusMessage.set(status + ' Waiting for connection...'); } else { statusMessage.set(status); } } function startPollForConnection(delay = 3000) { pollHandle = setTimeout(async () => { switch (true) { // Closed already, ignore case !isMounted: return; // Found DevTools! case devtoolsHaveStarted(): setStatus(ConnectionStatus.Connected, CONNECTED); return; // Waiting for connection, but we do have an active Metro connection, lets force a reload to enter Dev Mode on app // prettier-ignore case connectionStatus.get() === ConnectionStatus.Initializing && !!metroDevice?.ws: setStatus( ConnectionStatus.WaitingForReload, "Sending 'reload' to Metro to force the DevTools to connect...", ); metroDevice!.sendCommand('reload'); startPollForConnection(2000); return; // Waiting for initial connection, but no WS bridge available case connectionStatus.get() === ConnectionStatus.Initializing: setStatus( ConnectionStatus.WaitingForReload, "The DevTools didn't connect yet. Please trigger the DevMenu in the React Native app, or Reload it to connect", ); startPollForConnection(10000); return; // Still nothing? Users might not have done manual action, or some other tools have picked it up? case connectionStatus.get() === ConnectionStatus.WaitingForReload: setStatus( ConnectionStatus.WaitingForReload, "The DevTools didn't connect yet. Please verify your React Native app is in development mode, and that no other instance of the React DevTools are attached to the app already.", ); startPollForConnection(); return; } }, delay); } function devtoolsHaveStarted() { return (findDevToolsNode()?.childElementCount ?? 0) > 0; } async function initializeDevTools(devToolsNode: HTMLElement) { try { setStatus(ConnectionStatus.Initializing, 'Waiting for port 8097'); const port = await getPort({port: 8097}); // default port for dev tools setStatus( ConnectionStatus.Initializing, 'Starting DevTools server on ' + port, ); // Currently a new port is negotatiated every time the plugin is opened. // This can be potentially optimized by keeping the devTools instance around startResult = devToolsInstance .setContentDOMNode(devToolsNode) .setStatusListener((status) => { setStatus(ConnectionStatus.Initializing, status); }) .startServer(port) as any; setStatus(ConnectionStatus.Initializing, 'Waiting for device'); // This is a hack that should be cleaned up. Instead of setting up port forwarding // for any physical android device, we should introduce a mechanism to detect all connected // metro apps, and connect to one of them. // Since this is not how we want (or can) reliably detect the device we intend to interact with, // leaving this here until we can get a list of connected applications & ports from Metro or Flipper (window as any).__SECRET_FLIPPER_STORE_DONT_USE_OR_YOU_WILL_BE_FIRED__ .getState() .connections.devices.forEach((d: any) => { if ( (d.deviceType === 'physical' && d.os === 'Android') || SUPPORTED_OCULUS_DEVICE_TYPES.includes(d.title.toLowerCase()) ) { console.log( `[React DevTools] Forwarding port ${port} for device ${d.title}`, ); d.reverse([port]); } }); } catch (e) { console.error('Failed to initalize React DevTools' + e); setStatus(ConnectionStatus.Error, 'Failed to initialize DevTools: ' + e); } } function stopDevtools() { isMounted = false; if (pollHandle) { clearTimeout(pollHandle); } const devToolsNode = findDevToolsNode(); if (devToolsNode) { detachDevTools(devToolsNode); } } client.onReady(() => { console.log('searching'); findGlobalDevTools().then((path) => { globalDevToolsPath.set(path + '/standalone'); if (path) { console.log('Found global React DevTools: ', path); } }); }); return { devtoolsHaveStarted, connectionStatus, statusMessage, bootDevTools, metroDevice, containerRef, stopDevtools, globalDevToolsPath, useGlobalDevTools, toggleUseGlobalDevTools, }; } export function Component() { const instance = usePlugin(devicePlugin); const connectionStatus = useValue(instance.connectionStatus); const statusMessage = useValue(instance.statusMessage); const globalDevToolsPath = useValue(instance.globalDevToolsPath); const useGlobalDevTools = useValue(instance.useGlobalDevTools); useEffect(() => { instance.bootDevTools(); return instance.stopDevtools; }, [instance]); return ( {globalDevToolsPath ? ( Use globally installed DevTools } /> ) : null} {!instance.devtoolsHaveStarted() ? ( {(connectionStatus === ConnectionStatus.WaitingForReload && instance.metroDevice?.ws) || connectionStatus === ConnectionStatus.Error ? ( ) : null} ) : null} ); }