Files
flipper/desktop/app/src/server/FlipperServerImpl.tsx
Michel Weststrate 2d838efd4d Separate device in server and client version [2/n]
Summary:
This stack takes care of handling care of moving all device interactions over the (possible) async channel FlipperServer. The FlipperServer interface (see previous diff) allows listening to specific server events using `on`, and emit commands to be executed by the server by using `exec` (e.g. `exec('take-screenshot', serial) => Promise<buffer>`).

FlipperServerImpl implements this interface on the server side.

The device implementations are split as follows

```
server / backend process:

ServerDevice
- iOSDevice
- AndroidDevice
- MetroDevice
- DummyDevice
- Mac/Windows Device

frontend / ui:

BaseDevice: a normal connected, device, implements device apis as they already existed
- ArchivedDevice (note that this doesn't have a server counterpart)
- TestDevice (for unit tests, with stubbed backend communication)

```

All features of devices are for simplicity unified (since the deviations are small), where specific device types might not implement certain features like taking screenshots or running shell commands.

To avoid making this diff unnecessarily big, some open Todo's will be addressed later in this stack, and it shouldn't be landed alone.

Reviewed By: timur-valiev

Differential Revision: D30909346

fbshipit-source-id: cce0bee94fdd5db59bebe3577a6084219a038719
2021-09-22 09:03:32 -07:00

318 lines
9.8 KiB
TypeScript

/**
* 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 EventEmitter from 'events';
import Client from '../Client';
import {Store} from '../reducers/index';
import {Logger} from '../fb-interfaces/Logger';
import ServerController from './comms/ServerController';
import {UninitializedClient} from './UninitializedClient';
import {addErrorNotification} from '../reducers/notifications';
import {CertificateExchangeMedium} from './utils/CertificateProvider';
import {isLoggedIn} from '../fb-stubs/user';
import React from 'react';
import {Typography} from 'antd';
import {
ACTIVE_SHEET_SIGN_IN,
ServerPorts,
setActiveSheet,
} from '../reducers/application';
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,
} from 'flipper-plugin';
import {ServerDevice} from './devices/ServerDevice';
import {Base64} from 'js-base64';
import MetroDevice from './devices/metro/MetroDevice';
export interface FlipperServerConfig {
enableAndroid: boolean;
androidHome: string;
enableIOS: boolean;
idbPath: string;
enablePhysicalIOS: boolean;
serverPorts: ServerPorts;
}
// defaultConfig should be used for testing only, and disables by default all features
const defaultConfig: FlipperServerConfig = {
androidHome: '',
enableAndroid: false,
enableIOS: false,
enablePhysicalIOS: false,
idbPath: '',
serverPorts: {
insecure: -1,
secure: -1,
},
};
/**
* 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 {
public config: FlipperServerConfig;
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<string, ServerDevice>();
state: FlipperServerState = 'pending';
android: AndroidDeviceManager;
ios: IOSDeviceManager;
// TODO: remove store argument
constructor(
config: Partial<FlipperServerConfig>,
/** @deprecated remove! */
public store: Store,
public logger: Logger,
) {
this.config = {...defaultConfig, ...config};
const server = (this.server = new ServerController(this));
this.android = new AndroidDeviceManager(this);
this.ios = new IOSDeviceManager(this);
server.addListener('new-client', (client: Client) => {
this.emit('client-connected', client);
});
server.addListener('error', (err) => {
this.emit('server-error', err);
});
server.addListener('start-client-setup', (client: UninitializedClient) => {
this.store.dispatch({
type: 'START_CLIENT_SETUP',
payload: client,
});
});
server.addListener(
'client-setup-error',
({client, error}: {client: UninitializedClient; error: Error}) => {
this.store.dispatch(
addErrorNotification(
`Connection to '${client.appName}' on '${client.deviceName}' failed`,
'Failed to start client connection',
error,
),
);
},
);
server.addListener(
'client-unresponsive-error',
({
client,
medium,
}: {
client: UninitializedClient;
medium: CertificateExchangeMedium;
deviceID: string;
}) => {
this.store.dispatch(
addErrorNotification(
`Timed out establishing connection with "${client.appName}" on "${client.deviceName}".`,
medium === 'WWW' ? (
<>
Verify that both your computer and mobile device are on
Lighthouse/VPN{' '}
{!isLoggedIn().get() && (
<>
and{' '}
<Typography.Link
onClick={() =>
this.store.dispatch(
setActiveSheet(ACTIVE_SHEET_SIGN_IN),
)
}>
log in to Facebook Intern
</Typography.Link>
</>
)}{' '}
so they can exchange certificates.{' '}
<Typography.Link href="https://www.internalfb.com/intern/wiki/Ops/Network/Enterprise_Network_Engineering/ene_wlra/VPN_Help/Vpn/mobile/">
Check this link
</Typography.Link>{' '}
on how to enable VPN on mobile device.
</>
) : (
'Verify that your client is connected to Flipper and that there is no error related to idb.'
),
),
);
},
);
}
setServerState(state: FlipperServerState, error?: Error) {
this.state = state;
this.emit('server-state', {state, error});
}
/**
* Starts listening to parts and watching for devices
*/
async start() {
if (this.state !== 'pending') {
throw new Error('Server already started');
}
this.setServerState('starting');
try {
await this.server.init();
await this.startDeviceListeners();
this.setServerState('started');
} catch (e) {
console.error('Failed to start FlipperServer', e);
this.setServerState('error', e);
}
}
async startDeviceListeners() {
this.disposers.push(
await this.android.watchAndroidDevices(),
await this.ios.watchIOSDevices(),
metroDevice(this),
desktopDevice(this),
);
}
on<Event extends keyof FlipperServerEvents>(
event: Event,
callback: (payload: FlipperServerEvents[Event]) => void,
): void {
this.events.on(event, callback);
}
off<Event extends keyof FlipperServerEvents>(
event: Event,
callback: (payload: FlipperServerEvents[Event]) => void,
): void {
this.events.off(event, callback);
}
/**
* @internal
*/
emit<Event extends keyof FlipperServerEvents>(
event: Event,
payload: FlipperServerEvents[Event],
): void {
this.events.emit(event, payload);
}
exec<Event extends keyof FlipperServerCommands>(
event: Event,
...args: Parameters<FlipperServerCommands[Event]>
): ReturnType<FlipperServerCommands[Event]> {
console.debug(`[FlipperServer] command ${event}: `, args);
const handler: (...args: any[]) => Promise<any> =
this.commandHandler[event];
if (!handler) {
throw new Error(`Unimplemented server command: ${event}`);
}
return handler(...args) as any;
}
private commandHandler: FlipperServerCommands = {
'device-start-logging': async (serial: string) =>
this.getDevice(serial).startLogging(),
'device-stop-logging': async (serial: string) =>
this.getDevice(serial).stopLogging(),
'device-supports-screenshot': async (serial: string) =>
this.getDevice(serial).screenshotAvailable(),
'device-supports-screencapture': async (serial: string) =>
this.getDevice(serial).screenCaptureAvailable(),
'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),
'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);
},
};
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;
}
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) {
throw new Error('No device with serial: ' + serial);
}
return device;
}
getDeviceSerials(): string[] {
return Array.from(this.devices.keys());
}
getDevices(): ServerDevice[] {
return Array.from(this.devices.values());
}
public async close() {
this.server.close();
for (const device of this.devices.values()) {
device.disconnect();
}
this.disposers.forEach((f) => f?.());
this.setServerState('closed');
}
}