From c685493db0cb6c57ef45498545fe1cbabe429577 Mon Sep 17 00:00:00 2001 From: Timur Valiev Date: Fri, 22 Nov 2019 03:09:41 -0800 Subject: [PATCH] JS apps support 1/n Summary: ### Connecting Flipper with JS apps by using electron's BrowserWindow and IPC 1. UI: there is a menu item in Devices tab which opens JS Emulator Launcher Sheet. Here we can configure URL to open and initial size of the window. 2. BrowserWindow, preloaded js: there is SupportJSClientPreload.js which initialize communication between flipper and app via electron's ipc 3. On flipper's side there is src/utils/js-client/serverUtils.tsx which contains most of JS emulator related code 4. Extracting of FlipperClientConnection: since we don't use RScocket to communicate with JS app I extracted needed methods to FlipperClientConnection (located in Client) and partly implemented them in JSClientFlipperConnection (requestResponse is just send a message now, doesn't return actual result) Reviewed By: jknoxville Differential Revision: D18572882 fbshipit-source-id: 56d1ca1a60ed2e51329b917021a09382cbb1ceec --- src/App.tsx | 4 + src/Client.tsx | 14 +- src/chrome/DevicesButton.tsx | 29 +++- src/chrome/JSEmulatorLauncherSheet.tsx | 122 ++++++++++++++ src/devices/BaseDevice.tsx | 2 +- src/devices/JSDevice.tsx | 20 +++ src/reducers/application.tsx | 3 + src/server.tsx | 13 +- src/utils/js-client/serverUtils.tsx | 210 +++++++++++++++++++++++++ static/SupportJSClientPreload.js | 90 +++++++++++ 10 files changed, 496 insertions(+), 11 deletions(-) create mode 100644 src/chrome/JSEmulatorLauncherSheet.tsx create mode 100644 src/devices/JSDevice.tsx create mode 100644 src/utils/js-client/serverUtils.tsx create mode 100644 static/SupportJSClientPreload.js diff --git a/src/App.tsx b/src/App.tsx index 008c99c99..f58d2d9ed 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import ShareSheetExportUrl from './chrome/ShareSheetExportUrl'; import SignInSheet from './chrome/SignInSheet'; import ExportDataPluginSheet from './chrome/ExportDataPluginSheet'; import ShareSheetExportFile from './chrome/ShareSheetExportFile'; +import JSEmulatorLauncherSheet from './chrome/JSEmulatorLauncherSheet'; import PluginContainer from './PluginContainer'; import Sheet from './chrome/Sheet'; import {ipcRenderer, remote} from 'electron'; @@ -34,6 +35,7 @@ import { ACTIVE_SHEET_SHARE_DATA_IN_FILE, ACTIVE_SHEET_SELECT_PLUGINS_TO_EXPORT, ACTIVE_SHEET_PLUGIN_SHEET, + ACTIVE_SHEET_JS_EMULATOR_LAUNCHER, } from './reducers/application'; import {Logger} from './fb-interfaces/Logger'; import BugReporter from './fb-stubs/BugReporter'; @@ -122,6 +124,8 @@ export class App extends React.Component { case ACTIVE_SHEET_PLUGIN_SHEET: // Currently unused. return null; + case ACTIVE_SHEET_JS_EMULATOR_LAUNCHER: + return ; default: return null; } diff --git a/src/Client.tsx b/src/Client.tsx index 10b3ff609..2dfce3c38 100644 --- a/src/Client.tsx +++ b/src/Client.tsx @@ -13,7 +13,8 @@ import {App} from './App.js'; import {Logger} from './fb-interfaces/Logger'; import {Store} from './reducers/index'; import {setPluginState} from './reducers/pluginStates'; -import {RSocketClientSocket} from 'rsocket-core/RSocketClient'; +import {Payload, ConnectionStatus} from 'rsocket-types'; +import {Flowable, Single} from 'rsocket-flowable'; import {performance} from 'perf_hooks'; import {reportPlatformFailures, reportPluginFailures} from './utils/metrics'; import {notNull} from './utils/typeUtils'; @@ -97,6 +98,13 @@ const handleError = ( } }; +export interface FlipperClientConnection { + connectionStatus(): Flowable; + close(): void; + fireAndForget(payload: Payload): void; + requestResponse(payload: Payload): Single>; +} + export default class Client extends EventEmitter { app: App | undefined; connected: boolean; @@ -105,7 +113,7 @@ export default class Client extends EventEmitter { sdkVersion: number; messageIdCounter: number; plugins: Plugins; - connection: RSocketClientSocket | null | undefined; + connection: FlipperClientConnection | null | undefined; store: Store; activePlugins: Set; device: Promise; @@ -129,7 +137,7 @@ export default class Client extends EventEmitter { constructor( id: string, query: ClientQuery, - conn: RSocketClientSocket | null | undefined, + conn: FlipperClientConnection | null | undefined, logger: Logger, store: Store, plugins?: Plugins | null | undefined, diff --git a/src/chrome/DevicesButton.tsx b/src/chrome/DevicesButton.tsx index a27ccf988..f027a67f9 100644 --- a/src/chrome/DevicesButton.tsx +++ b/src/chrome/DevicesButton.tsx @@ -12,11 +12,17 @@ import {connect, ReactReduxContext} from 'react-redux'; import {spawn} from 'child_process'; import {dirname} from 'path'; import {selectDevice, preferDevice} from '../reducers/connections'; +import { + setActiveSheet, + ActiveSheet, + ACTIVE_SHEET_JS_EMULATOR_LAUNCHER, +} from '../reducers/application'; import {default as which} from 'which'; import {showOpenDialog} from '../utils/exportData'; import BaseDevice from '../devices/BaseDevice'; import React, {Component} from 'react'; import {State} from '../reducers'; +import GK from '../fb-stubs/GK'; type StateFromProps = { selectedDevice: BaseDevice | null | undefined; @@ -27,6 +33,7 @@ type StateFromProps = { type DispatchFromProps = { selectDevice: (device: BaseDevice) => void; preferDevice: (device: string) => void; + setActiveSheet: (sheet: ActiveSheet) => void; }; type OwnProps = {}; @@ -154,17 +161,30 @@ class DevicesButton extends Component { label: name, click: () => this.launchEmulator(name), })); - if (emulators.length > 0) { + + // Launch JS emulator + if (GK.get('flipper_js_client_emulator')) { + if (emulators.length > 0) { + dropdown.push( + {type: 'separator' as 'separator'}, + { + label: 'Launch Android emulators', + enabled: false, + }, + ...emulators, + ); + } dropdown.push( {type: 'separator' as 'separator'}, { - label: 'Launch Android emulators', - enabled: false, + label: 'Launch JS Web App', + click: () => + this.props.setActiveSheet(ACTIVE_SHEET_JS_EMULATOR_LAUNCHER), }, - ...emulators, ); } } + if (dropdown.length > 0) { dropdown.push({type: 'separator' as 'separator'}); } @@ -196,5 +216,6 @@ export default connect( { selectDevice, preferDevice, + setActiveSheet, }, )(DevicesButton); diff --git a/src/chrome/JSEmulatorLauncherSheet.tsx b/src/chrome/JSEmulatorLauncherSheet.tsx new file mode 100644 index 000000000..9cca353e6 --- /dev/null +++ b/src/chrome/JSEmulatorLauncherSheet.tsx @@ -0,0 +1,122 @@ +/** + * 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 { + FlexColumn, + Button, + styled, + Text, + FlexRow, + Spacer, + Input, + Label, +} from 'flipper'; +import React, {Component} from 'react'; +import {connect} from 'react-redux'; +import {State as Store} from '../reducers'; +import {launchJsEmulator} from '../utils/js-client/serverUtils'; + +const Container = styled(FlexColumn)({ + padding: 20, + width: 800, +}); + +const Title = styled(Text)({ + marginBottom: 18, + marginRight: 10, + fontWeight: 100, + fontSize: '40px', +}); + +const textareaStyle = { + margin: 0, + marginBottom: 10, +}; + +const TitleInput = styled(Input)({ + ...textareaStyle, + height: 30, +}); + +type OwnProps = { + onHide: () => void; +}; + +type StateFromProps = {}; + +type DispatchFromProps = {}; + +type State = { + url: string; + width: number; + height: number; +}; + +type Props = OwnProps & StateFromProps & DispatchFromProps; +class JSEmulatorLauncherSheet extends Component { + state: State = { + url: 'http://localhost:8888', + width: 800, + height: 600, + }; + + onUrlChange = (e: React.ChangeEvent) => { + this.setState({url: e.target.value}); + }; + + onHeightChange = (e: React.ChangeEvent) => { + this.setState({height: Number(e.target.value)}); + }; + + onWidthChange = (e: React.ChangeEvent) => { + this.setState({width: Number(e.target.value)}); + }; + + render() { + const {url, height, width} = this.state; + return ( + + Launch Web App + + + + + + + +
+ + + + + +
+ ); + } +} + +export default connect( + () => ({}), + {}, +)(JSEmulatorLauncherSheet); diff --git a/src/devices/BaseDevice.tsx b/src/devices/BaseDevice.tsx index a767495ca..4de942deb 100644 --- a/src/devices/BaseDevice.tsx +++ b/src/devices/BaseDevice.tsx @@ -52,7 +52,7 @@ export type DeviceExport = { logs: Array; }; -export type OS = 'iOS' | 'Android' | 'Windows' | 'MacOS'; +export type OS = 'iOS' | 'Android' | 'Windows' | 'MacOS' | 'JSWebApp'; export default class BaseDevice { constructor(serial: string, deviceType: DeviceType, title: string, os: OS) { diff --git a/src/devices/JSDevice.tsx b/src/devices/JSDevice.tsx new file mode 100644 index 000000000..b5691739e --- /dev/null +++ b/src/devices/JSDevice.tsx @@ -0,0 +1,20 @@ +/** + * 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 BaseDevice from './BaseDevice'; + +export default class JSDevice extends BaseDevice { + webContentsId: number; + + constructor(serial: string, title: string, webContentsId: number) { + super(serial, 'emulator', title, 'JSWebApp'); + this.devicePlugins = []; + this.webContentsId = webContentsId; + } +} diff --git a/src/reducers/application.tsx b/src/reducers/application.tsx index 562c6a10f..db0ecb2f4 100644 --- a/src/reducers/application.tsx +++ b/src/reducers/application.tsx @@ -26,6 +26,8 @@ export const ACTIVE_SHEET_SHARE_DATA_IN_FILE: 'SHARE_DATA_IN_FILE' = export const SET_EXPORT_STATUS_MESSAGE: 'SET_EXPORT_STATUS_MESSAGE' = 'SET_EXPORT_STATUS_MESSAGE'; export const UNSET_SHARE: 'UNSET_SHARE' = 'UNSET_SHARE'; +export const ACTIVE_SHEET_JS_EMULATOR_LAUNCHER: 'ACTIVE_SHEET_JS_EMULATOR_LAUNCHER' = + 'ACTIVE_SHEET_JS_EMULATOR_LAUNCHER'; export type ActiveSheet = | typeof ACTIVE_SHEET_PLUGIN_SHEET @@ -37,6 +39,7 @@ export type ActiveSheet = | typeof ACTIVE_SHEET_DOCTOR | typeof ACTIVE_SHEET_SHARE_DATA_IN_FILE | typeof ACTIVE_SHEET_SELECT_PLUGINS_TO_EXPORT + | typeof ACTIVE_SHEET_JS_EMULATOR_LAUNCHER | null; export type LauncherMsg = { diff --git a/src/server.tsx b/src/server.tsx index cbb184850..1e15ce557 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -16,17 +16,19 @@ import {RSocketServer} from 'rsocket-core'; import RSocketTCPServer from 'rsocket-tcp-server'; import {Single} from 'rsocket-flowable'; import Client from './Client'; +import {FlipperClientConnection} from './Client'; import {UninitializedClient} from './UninitializedClient'; import {reportPlatformFailures} from './utils/metrics'; import EventEmitter from 'events'; import invariant from 'invariant'; import tls from 'tls'; import net, {Socket} from 'net'; -import {RSocketClientSocket} from 'rsocket-core/RSocketClient'; import {Responder, Payload, ReactiveSocket} from 'rsocket-types'; +import GK from './fb-stubs/GK'; +import {initJsEmulatorIPC} from './utils/js-client/serverUtils'; type ClientInfo = { - connection: RSocketClientSocket | null | undefined; + connection: FlipperClientConnection | null | undefined; client: Client; }; @@ -84,6 +86,11 @@ class Server extends EventEmitter { return; }); reportPlatformFailures(this.initialisePromise, 'initializeServer'); + + if (GK.get('flipper_js_client_emulator')) { + initJsEmulatorIPC(this.store, this.logger, this, this.connections); + } + return this.initialisePromise; } @@ -304,7 +311,7 @@ class Server extends EventEmitter { } async addConnection( - conn: RSocketClientSocket, + conn: FlipperClientConnection, query: ClientQuery, csrQuery: ClientCsrQuery, ): Promise { diff --git a/src/utils/js-client/serverUtils.tsx b/src/utils/js-client/serverUtils.tsx new file mode 100644 index 000000000..b31f8ffe6 --- /dev/null +++ b/src/utils/js-client/serverUtils.tsx @@ -0,0 +1,210 @@ +/** + * 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 Client, {ClientQuery} from '../../Client'; +import {FlipperClientConnection} from '../../Client'; +import {ipcRenderer, remote} from 'electron'; +import JSDevice from '../../devices/JSDevice'; +import {Store} from 'src/reducers'; +import {Logger} from 'src/fb-interfaces/Logger'; + +import {Payload, ConnectionStatus, ISubscriber} from 'rsocket-types'; +import {Flowable, Single} from 'rsocket-flowable'; +import Server from 'src/server'; + +const connections: Map> = new Map(); + +const availablePlugins: Map> = new Map(); + +function jsDeviceId(windowId: number): string { + return 'test_js_device' + windowId; +} + +export function initJsEmulatorIPC( + store: Store, + logger: Logger, + flipperServer: Server, + flipperConnections: Map< + string, + { + connection: FlipperClientConnection | null | undefined; + client: Client; + } + >, +) { + ipcRenderer.on('from-js-emulator-init-client', (_event, message) => { + const {windowId} = message; + const {plugins, appName} = message.payload; + store.dispatch({ + type: 'REGISTER_DEVICE', + payload: new JSDevice(jsDeviceId(windowId), 'jsEmulator', windowId), + }); + + const connection = new JSClientFlipperConnection(windowId); + connections.set(windowId, connection); + availablePlugins.set(windowId, plugins); + + const query: ClientQuery = { + app: appName, + os: 'JSWebApp', + device: 'jsEmulator', + device_id: jsDeviceId(windowId), + sdk_version: 2, // hack to bybass callbacks in Client, will be fixed when JS Connection will be fully implemented + }; + const clientId = `${query.app}#${query.os}#${query.device}#${query.device_id}`; + + const client = new Client( + clientId, + query, + connection, + logger, + store, + plugins, + ); + + flipperConnections.set(clientId, {connection: connection, client: client}); + + connection.connectionStatus().subscribe({ + onNext(payload) { + if (payload.kind == 'ERROR' || payload.kind == 'CLOSED') { + console.debug(`Device disconnected ${client.id}`, 'server'); + flipperServer.removeConnection(client.id); + const toUnregister = new Set(); + toUnregister.add(jsDeviceId(windowId)); + store.dispatch({ + type: 'UNREGISTER_DEVICES', + payload: toUnregister, + }); + connections.delete(windowId); + availablePlugins.delete(windowId); + } + }, + onSubscribe(subscription) { + subscription.request(Number.MAX_SAFE_INTEGER); + }, + }); + + client.init().then(() => { + console.log(client); + flipperServer.emit('new-client', client); + flipperServer.emit('clients-change'); + client.emit('plugins-change'); + + ipcRenderer.on('from-js-emulator', (_event, message) => { + const {command, payload} = message; + if (command === 'sendFlipperObject') { + client.onMessage( + JSON.stringify({ + params: { + api: payload.api, + method: payload.method, + params: JSON.parse(payload.params), + }, + method: 'execute', + }), + ); + } + }); + }); + }); +} + +export function launchJsEmulator(url: string, height: number, width: number) { + const BrowserWindow = remote.BrowserWindow; + const win = new BrowserWindow({ + height: height, + width: width, + webPreferences: { + preload: require('path').join( + remote.app.getAppPath(), + 'SupportJSClientPreload.js', + ), + nodeIntegration: false, + contextIsolation: false, + allowRunningInsecureContent: true, + }, + }); + + win.webContents.on('preload-error', (_event, path, error) => { + console.log(path, error); + }); + + win.loadURL(url); + + win.webContents.on('did-finish-load', () => { + win.webContents.send('parent-window-id', remote.getCurrentWebContents().id); + + const childWindowId = win.webContents.id; + win.on('closed', () => { + connections.get(childWindowId)?.close(); + }); + }); +} + +export class JSClientFlipperConnection + implements FlipperClientConnection { + webContentsId: number; + connStatusSubscribers: Set> = new Set(); + connStatus: ConnectionStatus; + + constructor(webContentsId: number) { + this.webContentsId = webContentsId; + this.connStatus = {kind: 'CONNECTED'}; + } + + connectionStatus(): Flowable { + return new Flowable(subscriber => { + subscriber.onSubscribe({ + cancel: () => { + this.connStatusSubscribers.delete(subscriber); + }, + request: _ => { + this.connStatusSubscribers.add(subscriber); + subscriber.onNext(this.connStatus); + }, + }); + }); + } + + close(): void { + this.connStatus = {kind: 'CLOSED'}; + this.connStatusSubscribers.forEach(subscriber => { + subscriber.onNext(this.connStatus); + }); + } + + fireAndForget(payload: Payload): void { + ipcRenderer.sendTo( + this.webContentsId, + 'message-to-plugin', + JSON.parse(payload.data != null ? payload.data : '{}'), + ); + } + + // TODO: fully implement and return actual result + requestResponse(payload: Payload): Single> { + return new Single(subscriber => { + const method = + payload.data != null ? JSON.parse(payload.data).method : 'not-defined'; + if (method != 'getPlugins') { + this.fireAndForget(payload); + } + subscriber.onSubscribe(() => {}); + subscriber.onComplete( + method == 'getPlugins' + ? { + data: JSON.stringify({ + success: {plugins: availablePlugins.get(this.webContentsId)}, + }), + } + : {data: JSON.stringify({success: null})}, + ); + }); + } +} diff --git a/static/SupportJSClientPreload.js b/static/SupportJSClientPreload.js new file mode 100644 index 000000000..93dca57cb --- /dev/null +++ b/static/SupportJSClientPreload.js @@ -0,0 +1,90 @@ +/** + * 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 + */ + +// ============== +// Preload script +// ============== +const {remote, ipcRenderer} = require('electron'); + +let FlipperMainWindowId = 0; + +ipcRenderer.on('parent-window-id', (event, message) => { + FlipperMainWindowId = message; +}); + +let FlipperIsClientInit = false; +let FlipperMemoizedPlugins; + +function initClient(plugins) { + if (FlipperIsClientInit) { + return; + } + if (plugins) { + FlipperMemoizedPlugins = plugins; + } + if (FlipperMainWindowId != 0) { + ipcRenderer.sendTo(FlipperMainWindowId, 'from-js-emulator-init-client', { + command: 'initClient', + windowId: remote.getCurrentWebContents().id, + payload: { + plugins: plugins ? plugins : FlipperMemoizedPlugins, + appName: 'kite/weblite', + }, + }); + FlipperIsClientInit = true; + } +} + +window.FlipperWebviewBridge = { + registerPlugins: function(plugins) { + console.log(plugins); + if (FlipperMainWindowId != 0) { + ipcRenderer.sendTo(FlipperMainWindowId, 'from-js-emulator', { + command: 'registerPlugins', + payload: plugins, + }); + } + }, + start: function() { + console.log('start'); + + if (FlipperMainWindowId != 0) { + ipcRenderer.sendTo(FlipperMainWindowId, 'from-js-emulator', { + command: 'start', + payload: null, + }); + } + }, + sendFlipperObject: function(plugin, method, data) { + console.log(plugin, method, data); + initClient(); + if (FlipperMainWindowId != 0) { + ipcRenderer.sendTo(FlipperMainWindowId, 'from-js-emulator', { + command: 'sendFlipperObject', + payload: { + api: plugin, + method: method, + params: data, + }, + }); + } + }, + isFlipperSupported: true, + initClient: initClient, +}; + +ipcRenderer.on('message-to-plugin', (event, message) => { + const flipper = window.flipper; + if (!flipper) { + return; + } + const receiver = flipper.FlipperWebviewMessageReceiver.receive; + const {api, method, params} = message.params; + receiver(api, method, JSON.stringify(params)); +});