/** * 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. * * @format */ import './utils/macCa'; import './utils/fetch-polyfill'; import EventEmitter from 'events'; import {ServerController} from './app-connectivity/ServerController'; import {AndroidDeviceManager} from './devices/android/androidDeviceManager'; import {IOSDeviceManager} from './devices/ios/iOSDeviceManager'; import metroDevice from './devices/metro/metroDeviceManager'; import desktopDevice from './devices/desktop/desktopDeviceManager'; import { FlipperServerEvents, FlipperServerState, FlipperServerCommands, FlipperServer, UninitializedClient, FlipperServerConfig, Logger, FlipperServerExecOptions, DeviceDebugData, CertificateExchangeMedium, Settings, } from 'flipper-common'; import {ServerDevice} from './devices/ServerDevice'; import {Base64} from 'js-base64'; import MetroDevice from './devices/metro/MetroDevice'; import {launchEmulator} from './devices/android/AndroidDevice'; import {setFlipperServerConfig} from './FlipperServerConfig'; import {saveSettings} from './utils/settings'; import {saveLauncherSettings} from './utils/launcherSettings'; import {KeytarManager, KeytarModule, SERVICE_FLIPPER} from './utils/keytar'; import {PluginManager} from './plugins/PluginManager'; import {runHealthcheck, getHealthChecks} from './utils/runHealthchecks'; import {openFile} from './utils/openFile'; import {getChangelog} from './utils/pathUtils'; import {sendScribeLogs} from './fb-stubs/sendScribeLogs'; import { internGraphGETAPIRequest, internGraphPOSTAPIRequest, } from './fb-stubs/internRequests'; import {commandNodeApiExec} from './commands/NodeApiExec'; import {commandDownloadFileStartFactory} from './commands/DownloadFile'; import {promises} from 'fs'; // Electron 11 runs on Node 12 which does not support fs.promises.rm import rm from 'rimraf'; import assert from 'assert'; import {initializeAdbClient} from './devices/android/adbClient'; import {assertNotNull} from './app-connectivity/Utilities'; import {mkdirp} from 'fs-extra'; import {flipperDataFolder, flipperSettingsFolder} from './utils/paths'; import {DebuggableDevice} from './devices/DebuggableDevice'; import {jfUpload} from './fb-stubs/jf'; import path from 'path'; const {access, copyFile, mkdir, unlink, stat, readlink, readFile, writeFile} = promises; function isHandledStartupError(e: Error) { if (e.message.includes('EADDRINUSE')) { return true; } return false; } function setProcessState(settings: Settings) { const androidHome = settings.androidHome; const idbPath = settings.idbPath; if (!process.env.ANDROID_HOME && !process.env.ANDROID_SDK_ROOT) { process.env.ANDROID_HOME = androidHome; process.env.ANDROID_SDK_ROOT = androidHome; } // emulator/emulator is more reliable than tools/emulator, so prefer it if // it exists process.env.PATH = ['emulator', 'tools', 'platform-tools'] .map((directory) => path.resolve(androidHome, directory)) .join(':') + `:${idbPath}` + `:${process.env.PATH}`; } /** * FlipperServer takes care of all incoming device & client connections. * It will set up managers per device type, and create the incoming * RSocket/WebSocket server to handle incoming client connections. * * The server should be largely treated as event emitter, by listening to the relevant events * using '.on'. All events are strongly typed. */ export class FlipperServerImpl implements FlipperServer { private readonly events = new EventEmitter(); // server handles the incoming RSocket / WebSocket connections from Flipper clients readonly server: ServerController; readonly disposers: ((() => void) | void)[] = []; private readonly devices = new Map(); state: FlipperServerState = 'pending'; stateError: string | undefined = undefined; android?: AndroidDeviceManager; ios?: IOSDeviceManager; keytarManager: KeytarManager; pluginManager: PluginManager; unresponsiveClients: Set = new Set(); constructor( public config: FlipperServerConfig, public logger: Logger, keytarModule?: KeytarModule, ) { setFlipperServerConfig(config); console.log( 'Loaded flipper config, paths: ' + JSON.stringify(config.paths, null, 2), ); setProcessState(config.settings); const server = (this.server = new ServerController(this)); this.keytarManager = new KeytarManager(keytarModule); // given flipper-dump, it might make more sense to have the plugin command // handling (like download, install, etc) moved to flipper-server & app, // but let's keep things simple for now this.pluginManager = new PluginManager(this); server.addListener('error', (err) => { this.emit('server-error', err); }); server.addListener('start-client-setup', (client: UninitializedClient) => { this.emit('client-setup', client); }); server.addListener( 'client-setup-error', ({client, error}: {client: UninitializedClient; error: Error}) => { this.emit('connectivity-troubleshoot-notification', { title: `Connection to '${client.appName}' on '${client.deviceName}' failed`, description: `Failed to start client connection: ${error}`, type: 'error', }); }, ); server.addListener( 'client-unresponsive-error', ({ client, medium, }: { client: UninitializedClient; medium: CertificateExchangeMedium; deviceID: string; }) => { const clientIdentifier = `${client.deviceName}#${client.appName}`; if (!this.unresponsiveClients.has(clientIdentifier)) { this.unresponsiveClients.add(clientIdentifier); this.emit('connectivity-troubleshoot-notification', { type: 'error', title: `Timed out establishing connection with "${client.appName}" on "${client.deviceName}".`, description: medium === 'WWW' ? `Verify that both your computer and mobile device are on Lighthouse/VPN that you are logged in to Facebook Intern so that certificates can be exhanged. See: https://fburl.com/flippervpn` : 'Verify that your client is connected to Flipper and that there is no error related to idb or adb.', }); } else { console.warn( `[conn] Client still unresponsive: "${client.appName}" on "${client.deviceName}"`, ); } }, ); } setServerState(state: FlipperServerState, error?: Error) { this.state = state; this.stateError = '' + error; this.emit('server-state', {state, error: this.stateError}); } /** * Starts listening to parts and watching for devices. * Connect never throws directly, but communicates * through server-state events */ async connect() { if (this.state !== 'pending') { throw new Error('Server already started'); } this.setServerState('starting'); try { await this.createFolders(); await this.server.init(); await this.pluginManager.start(); this.startDeviceListeners(); this.setServerState('started'); } catch (e) { if (!isHandledStartupError(e)) { console.error('Failed to start FlipperServer', e); } this.setServerState('error', e); throw e; } } private async createFolders() { await Promise.all([ mkdirp(flipperDataFolder), mkdirp(flipperSettingsFolder), ]); } async startDeviceListeners() { const asyncDeviceListenersPromises: Array> = []; if (this.config.settings.enableAndroid) { asyncDeviceListenersPromises.push( initializeAdbClient(this.config.settings) .then((adbClient) => { if (!adbClient) { return; } this.android = new AndroidDeviceManager(this, adbClient); return this.android.watchAndroidDevices(true); }) .catch((e) => { console.error( 'FlipperServerImpl.startDeviceListeners.watchAndroidDevices -> unexpected error', e, ); }), ); } if (this.config.settings.enableIOS) { this.ios = new IOSDeviceManager(this, this.config.settings); asyncDeviceListenersPromises.push( this.ios.watchIOSDevices().catch((e) => { console.error( 'FlipperServerImpl.startDeviceListeners.watchIOSDevices -> unexpected error', e, ); }), ); } const asyncDeviceListeners = await Promise.all( asyncDeviceListenersPromises, ); this.disposers.push( ...asyncDeviceListeners, metroDevice(this), desktopDevice(this), ); } on( event: Event, callback: (payload: FlipperServerEvents[Event]) => void, ): void { this.events.on(event, callback); } once( event: Event, callback: (payload: FlipperServerEvents[Event]) => void, ): void { this.events.once(event, callback); } off( event: Event, callback: (payload: FlipperServerEvents[Event]) => void, ): void { this.events.off(event, callback); } onAny(callback: (event: keyof FlipperServerEvents, payload: any) => void) { this.events.on('*', callback); } offAny(callback: (event: keyof FlipperServerEvents, payload: any) => void) { this.events.off('*', callback); } /** * @internal */ emit( event: Event, payload: FlipperServerEvents[Event], ): void { this.events.emit(event, payload); this.events.emit('*', event, payload); } private isExecWithOptions( argsAmbiguous: | [ FlipperServerExecOptions, Event, ...Parameters, ] | [Event, ...Parameters], ): argsAmbiguous is [ FlipperServerExecOptions, Event, ...Parameters, ] { return typeof argsAmbiguous[0] === 'object'; } exec( options: FlipperServerExecOptions, event: Event, ...args: Parameters ): ReturnType; exec( event: Event, ...args: Parameters ): ReturnType; async exec( ...argsAmbiguous: | [ FlipperServerExecOptions, Event, ...Parameters, ] | [Event, ...Parameters] ): Promise { let _timeout: number; let event: Event; let args: Parameters; if (this.isExecWithOptions(argsAmbiguous)) { _timeout = argsAmbiguous[0].timeout; event = argsAmbiguous[1]; args = argsAmbiguous.slice(2) as typeof args; } else { // _timeout is currently not used, so we are setting it to a random value. Update it to a meaningful timeout before using it! _timeout = 42; event = argsAmbiguous[0]; args = argsAmbiguous.slice(1) as typeof args; } try { const handler: (...args: any[]) => Promise = this.commandHandler[event]; if (!handler) { throw new Error(`Unimplemented server command: ${event}`); } const result = await handler(...args); console.debug(`[FlipperServer] command '${event}' - OK`); return result; } catch (e) { console.debug(`[FlipperServer] command '${event}' - ERROR: ${e} `); throw e; } } private commandHandler: FlipperServerCommands = { 'device-install-app': async (serial, bundlePath) => { return this.devices.get(serial)?.installApp(bundlePath); }, 'get-server-state': async () => ({ state: this.state, error: this.stateError, }), 'node-api-exec': commandNodeApiExec, 'node-api-fs-access': access, 'node-api-fs-pathExists': async (path, mode) => { try { await access(path, mode); return true; } catch { return false; } }, 'node-api-fs-unlink': unlink, 'node-api-fs-mkdir': mkdir, 'node-api-fs-rm': async (path, {maxRetries} = {}) => new Promise((resolve, reject) => rm(path, {disableGlob: true, maxBusyTries: maxRetries}, (err) => err ? reject(err) : resolve(), ), ), 'node-api-fs-copyFile': copyFile, 'node-api-fs-stat': async (path) => { const stats = await stat(path); const {atimeMs, birthtimeMs, ctimeMs, gid, mode, mtimeMs, size, uid} = stats; return { atimeMs, birthtimeMs, ctimeMs, gid, mode, mtimeMs, size, uid, isDirectory: stats.isDirectory(), isFile: stats.isFile(), isSymbolicLink: stats.isSymbolicLink(), }; }, 'node-api-fs-readlink': readlink, 'node-api-fs-readfile': async (path, options) => { const contents = await readFile(path, options ?? 'utf8'); assert( typeof contents === 'string', `File ${path} was not read with a string encoding`, ); return contents; }, 'node-api-fs-readfile-binary': async (path) => { const contents = await readFile(path); return Base64.fromUint8Array(contents); }, 'node-api-fs-writefile': (path, contents, options) => writeFile(path, contents, options ?? 'utf8'), 'node-api-fs-writefile-binary': (path, base64contents) => writeFile(path, Base64.toUint8Array(base64contents), 'binary'), // TODO: Do we need API to cancel an active download? 'download-file-start': commandDownloadFileStartFactory( this.emit.bind(this), ), 'get-config': async () => this.config, 'get-changelog': getChangelog, 'device-find': async (deviceSerial) => { return this.devices.get(deviceSerial)?.info; }, 'device-list': async () => { return Array.from(this.devices.values()).map((d) => d.info); }, 'device-take-screenshot': async (serial: string) => Base64.fromUint8Array(await this.getDevice(serial).screenshot()), 'device-start-screencapture': async (serial, destination) => this.getDevice(serial).startScreenCapture(destination), 'device-stop-screencapture': async (serial: string) => this.getDevice(serial).stopScreenCapture(), 'device-shell-exec': async (serial: string, command: string) => this.getDevice(serial).executeShell(command), 'device-forward-port': async (serial, local, remote) => this.getDevice(serial).forwardPort(local, remote), 'device-clear-logs': async (serial) => this.getDevice(serial).clearLogs(), 'device-navigate': async (serial, loc) => this.getDevice(serial).navigateToLocation(loc), 'fetch-debug-data': () => this.fetchDebugLogs(), 'metro-command': async (serial: string, command: string) => { const device = this.getDevice(serial); if (!(device instanceof MetroDevice)) { throw new Error('Not a Metro device: ' + serial); } device.sendCommand(command); }, 'client-find': async (clientId) => { return this.server.connections.get(clientId)?.client; }, 'client-list': async () => { return Array.from(this.server.connections.values()).map((c) => c.client); }, 'client-request': async (clientId, payload) => { this.server.connections.get(clientId)?.connection?.send(payload); }, 'client-request-response': async (clientId, payload) => { const client = this.server.connections.get(clientId); if (client && client.connection) { return await client.connection.sendExpectResponse(payload); } return { length: 0, error: { message: `Client '${clientId} is no longer connected, failed to deliver: ${JSON.stringify( payload, )}`, name: 'CLIENT_DISCONNECTED', stacktrace: '', }, }; }, 'android-get-emulators': async () => { assertNotNull(this.android); return this.android.getAndroidEmulators(); }, 'android-launch-emulator': async (name, coldBoot) => launchEmulator(this.config.settings.androidHome, name, coldBoot), 'android-adb-kill': async () => { assertNotNull(this.android); return this.android.adbKill(); }, 'ios-get-simulators': async (bootedOnly) => { assertNotNull(this.ios); return this.ios.getSimulators(bootedOnly); }, 'ios-launch-simulator': async (udid) => { assertNotNull(this.ios); return this.ios.launchSimulator(udid); }, 'ios-idb-kill': async () => { assertNotNull(this.ios); return this.ios.idbKill(); }, 'persist-settings': async (settings) => saveSettings(settings), 'persist-launcher-settings': async (settings) => saveLauncherSettings(settings), 'keychain-read': (service) => this.keytarManager.retrieveToken(service), 'keychain-write': (service, password) => this.keytarManager.writeKeychain(service, password), 'keychain-unset': (service) => this.keytarManager.unsetKeychain(service), 'plugins-load-dynamic-plugins': () => this.pluginManager.loadDynamicPlugins(), 'plugins-load-marketplace-plugins': () => this.pluginManager.loadMarketplacePlugins(), 'plugins-get-installed-plugins': () => this.pluginManager.getInstalledPlugins(), 'plugins-remove-plugins': (plugins) => this.pluginManager.removePlugins(plugins), 'plugin-start-download': (details) => this.pluginManager.downloadPlugin(details), 'plugins-get-updatable-plugins': (query) => this.pluginManager.getUpdatablePlugins(query), 'plugins-install-from-content': (contents) => { const bytes = Base64.toUint8Array(contents); const buffer = Buffer.from(bytes); return this.pluginManager.installPluginFromFileOrBuffer(buffer); }, 'plugins-install-from-marketplace': (name: string) => this.pluginManager.installPluginForMarketplace(name), 'plugins-install-from-npm': (name) => this.pluginManager.installPluginFromNpm(name), 'plugin-source': (path) => this.pluginManager.loadSource(path), 'plugins-server-add-on-start': (pluginName, details, owner) => this.pluginManager.startServerAddOn(pluginName, details, owner), 'plugins-server-add-on-stop': (pluginName, owner) => this.pluginManager.stopServerAddOn(pluginName, owner), 'plugins-server-add-on-request-response': async (payload) => { try { const serverAddOn = this.pluginManager.getServerAddOnForMessage(payload); assertNotNull(serverAddOn); return await serverAddOn.sendExpectResponse(payload); } catch { return { length: 0, error: { message: `Server add-on for message '${JSON.stringify( payload, )} is no longer running.`, name: 'SERVER_ADDON_STOPPED', stacktrace: '', }, }; } }, 'doctor-get-healthchecks': getHealthChecks, 'doctor-run-healthcheck': runHealthcheck, 'open-file': openFile, 'intern-graph-post': async (endpoint, formfields, filefields, options) => { const token = await this.keytarManager.retrieveToken(SERVICE_FLIPPER); return internGraphPOSTAPIRequest( endpoint, formfields, filefields, options, token, ); }, 'intern-graph-get': async (endpoint, params, options) => { const token = await this.keytarManager.retrieveToken(SERVICE_FLIPPER); return internGraphGETAPIRequest(endpoint, params, options, token); }, 'intern-upload-scribe-logs': sendScribeLogs, 'intern-cloud-upload': async (path) => { const uploadRes = await jfUpload(path); if (!uploadRes) { throw new Error('Upload failed'); } return uploadRes; }, shutdown: async () => { process.exit(0); }, 'is-logged-in': async () => { try { const token = await this.keytarManager.retrieveToken(SERVICE_FLIPPER); return !!token; } catch (e) { return false; } }, 'environment-info': async () => { return this.config.environmentInfo; }, }; registerDevice(device: ServerDevice) { // destroy existing device const {serial} = device.info; const existing = this.devices.get(serial); if (existing) { // assert different kind of devices aren't accidentally reusing the same serial if (Object.getPrototypeOf(existing) !== Object.getPrototypeOf(device)) { throw new Error( `Tried to register a new device type for existing serial '${serial}': Trying to replace existing '${ Object.getPrototypeOf(existing).constructor.name }' with a new '${Object.getPrototypeOf(device).constructor.name}`, ); } // clean up connection existing.disconnect(); } // register new device this.devices.set(device.info.serial, device); this.emit('device-connected', device.info); } unregisterDevice(serial: string) { const device = this.devices.get(serial); if (!device) { return; } this.devices.delete(serial); device.disconnect(); // we'll only destroy upon replacement this.emit('device-disconnected', device.info); } getDevice(serial: string): ServerDevice { const device = this.devices.get(serial); if (!device) { console.warn(`No device with serial ${serial}.`); throw new Error('No device with matching serial.'); } return device; } hasDevice(serial: string): boolean { return !!this.devices.get(serial); } getDeviceSerials(): string[] { return Array.from(this.devices.keys()); } getDevices(): ServerDevice[] { return Array.from(this.devices.values()); } private async fetchDebugLogs() { const debugDataForEachDevice = await Promise.all( [...this.devices.values()] .filter( (device) => device.connected && (device.info.os === 'Android' || device.info.os === 'iOS'), ) .map((device) => (device as unknown as DebuggableDevice) .readFlipperFolderForAllApps() .catch((e) => { console.warn( 'fetchDebugLogs -> could not fetch debug data', device.info.serial, e, ); }), ), ); return debugDataForEachDevice .filter((item): item is DeviceDebugData[] => !!item) .flat(); } public async close() { this.server.close(); for (const device of this.devices.values()) { device.disconnect(); } this.disposers.forEach((f) => f?.()); this.setServerState('closed'); } }