Move app/server to flipper-server-core
Summary: moved `app/src/server` to `flipper-server-core/src` and fixed any fallout from that (aka integration points I missed on the preparing diffs). Reviewed By: passy Differential Revision: D31541378 fbshipit-source-id: 8a7e0169ebefa515781f6e5e0f7b926415d4b7e9
This commit is contained in:
committed by
Facebook GitHub Bot
parent
3e7a6b1b4b
commit
d88b28330a
93
desktop/flipper-server-core/src/FlipperServerConfig.tsx
Normal file
93
desktop/flipper-server-core/src/FlipperServerConfig.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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 {isTest} from 'flipper-common';
|
||||
import {parseFlipperPorts} from './utils/environmentVariables';
|
||||
|
||||
export interface FlipperServerConfig {
|
||||
enableAndroid: boolean;
|
||||
androidHome: string;
|
||||
enableIOS: boolean;
|
||||
idbPath: string;
|
||||
enablePhysicalIOS: boolean;
|
||||
validWebSocketOrigins: string[];
|
||||
staticPath: string;
|
||||
tmpPath: string;
|
||||
}
|
||||
|
||||
// defaultConfig should be used for testing only, and disables by default all features
|
||||
const testConfig: FlipperServerConfig = {
|
||||
androidHome: '',
|
||||
enableAndroid: false,
|
||||
enableIOS: false,
|
||||
enablePhysicalIOS: false,
|
||||
idbPath: '',
|
||||
validWebSocketOrigins: [],
|
||||
staticPath: '/static/',
|
||||
tmpPath: '/temp/',
|
||||
};
|
||||
|
||||
let currentConfig: FlipperServerConfig | undefined = undefined;
|
||||
|
||||
export function getFlipperServerConfig(): FlipperServerConfig {
|
||||
if (!currentConfig) {
|
||||
if (isTest()) return testConfig;
|
||||
throw new Error('FlipperServerConfig has not been set');
|
||||
}
|
||||
return currentConfig;
|
||||
}
|
||||
|
||||
export function setFlipperServerConfig(config: FlipperServerConfig) {
|
||||
currentConfig = config;
|
||||
}
|
||||
|
||||
type ServerPorts = {
|
||||
insecure: number;
|
||||
secure: number;
|
||||
};
|
||||
|
||||
export function getServerPortsConfig(): {
|
||||
serverPorts: ServerPorts;
|
||||
altServerPorts: ServerPorts;
|
||||
} {
|
||||
let portOverrides: ServerPorts | undefined;
|
||||
if (process.env.FLIPPER_PORTS) {
|
||||
portOverrides = parseFlipperPorts(process.env.FLIPPER_PORTS);
|
||||
if (!portOverrides) {
|
||||
console.error(
|
||||
`Ignoring malformed FLIPPER_PORTS env variable:
|
||||
"${process.env.FLIPPER_PORTS || ''}".
|
||||
Example expected format: "1111,2222".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let portAltOverrides: ServerPorts | undefined;
|
||||
if (process.env.FLIPPER_ALT_PORTS) {
|
||||
portAltOverrides = parseFlipperPorts(process.env.FLIPPER_ALT_PORTS);
|
||||
if (!portAltOverrides) {
|
||||
console.error(
|
||||
`Ignoring malformed FLIPPER_ALT_PORTS env variable:
|
||||
"${process.env.FLIPPER_ALT_PORTS || ''}".
|
||||
Example expected format: "1111,2222".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
serverPorts: portOverrides ?? {
|
||||
insecure: 8089,
|
||||
secure: 8088,
|
||||
},
|
||||
altServerPorts: portAltOverrides ?? {
|
||||
insecure: 9089,
|
||||
secure: 9088,
|
||||
},
|
||||
};
|
||||
}
|
||||
279
desktop/flipper-server-core/src/FlipperServerImpl.tsx
Normal file
279
desktop/flipper-server-core/src/FlipperServerImpl.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* 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 {Logger} from 'flipper-common';
|
||||
import ServerController from './comms/ServerController';
|
||||
import {CertificateExchangeMedium} from './utils/CertificateProvider';
|
||||
import {AndroidDeviceManager} from './devices/android/androidDeviceManager';
|
||||
import {
|
||||
IOSDeviceManager,
|
||||
launchSimulator,
|
||||
} from './devices/ios/iOSDeviceManager';
|
||||
import metroDevice from './devices/metro/metroDeviceManager';
|
||||
import desktopDevice from './devices/desktop/desktopDeviceManager';
|
||||
import {
|
||||
FlipperServerEvents,
|
||||
FlipperServerState,
|
||||
FlipperServerCommands,
|
||||
FlipperServer,
|
||||
UninitializedClient,
|
||||
} 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 {getFlipperServerConfig} from './FlipperServerConfig';
|
||||
|
||||
/**
|
||||
* 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<string, ServerDevice>();
|
||||
state: FlipperServerState = 'pending';
|
||||
android: AndroidDeviceManager;
|
||||
ios: IOSDeviceManager;
|
||||
|
||||
constructor(public logger: Logger) {
|
||||
getFlipperServerConfig(); // Config should be available at this point!
|
||||
const server = (this.server = new ServerController(this));
|
||||
this.android = new AndroidDeviceManager(this);
|
||||
this.ios = new IOSDeviceManager(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('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;
|
||||
}) => {
|
||||
this.emit('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://www.internalfb.com/intern/wiki/Ops/Network/Enterprise_Network_Engineering/ene_wlra/VPN_Help/Vpn/mobile/`
|
||||
: '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),
|
||||
'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),
|
||||
'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-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 () => this.android.getAndroidEmulators(),
|
||||
'android-launch-emulator': async (name, coldBoot) =>
|
||||
launchEmulator(name, coldBoot),
|
||||
'ios-get-simulators': async (bootedOnly) =>
|
||||
this.ios.getSimulators(bootedOnly),
|
||||
'ios-launch-simulator': async (udid) => launchSimulator(udid),
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 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 {ClientResponseType} from 'flipper-common';
|
||||
import WebSocket from 'ws';
|
||||
import {
|
||||
ConnectionStatusChange,
|
||||
ConnectionStatus,
|
||||
ClientConnection,
|
||||
} from './ClientConnection';
|
||||
|
||||
export class BrowserClientFlipperConnection implements ClientConnection {
|
||||
websocket: WebSocket;
|
||||
connStatusSubscribers: Set<ConnectionStatusChange> = new Set();
|
||||
connStatus: ConnectionStatus;
|
||||
app: string;
|
||||
plugins: string[] | undefined = undefined;
|
||||
|
||||
constructor(ws: WebSocket, app: string, plugins: string[]) {
|
||||
this.websocket = ws;
|
||||
this.connStatus = ConnectionStatus.CONNECTED;
|
||||
this.app = app;
|
||||
this.plugins = plugins;
|
||||
}
|
||||
subscribeToEvents(subscriber: ConnectionStatusChange): void {
|
||||
this.connStatusSubscribers.add(subscriber);
|
||||
}
|
||||
send(data: any): void {
|
||||
this.websocket.send(
|
||||
JSON.stringify({
|
||||
type: 'send',
|
||||
app: this.app,
|
||||
payload: data != null ? data : {},
|
||||
}),
|
||||
);
|
||||
}
|
||||
sendExpectResponse(data: any): Promise<ClientResponseType> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const {id: callId = undefined, method = undefined} =
|
||||
data != null ? data : {};
|
||||
|
||||
if (method === 'getPlugins' && this.plugins != null) {
|
||||
resolve({
|
||||
success: {plugins: this.plugins},
|
||||
length: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.websocket.send(
|
||||
JSON.stringify({
|
||||
type: 'call',
|
||||
app: this.app,
|
||||
payload: data != null ? data : {},
|
||||
}),
|
||||
);
|
||||
|
||||
this.websocket.on('message', (message: string) => {
|
||||
const {app, payload} = JSON.parse(message);
|
||||
|
||||
if (app === this.app && payload?.id === callId) {
|
||||
resolve(payload);
|
||||
}
|
||||
});
|
||||
this.websocket.on('error', (error: Error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.connStatus = ConnectionStatus.CLOSED;
|
||||
this.connStatusSubscribers.forEach((subscriber) => {
|
||||
subscriber(this.connStatus);
|
||||
});
|
||||
this.websocket.send(JSON.stringify({type: 'disconnect', app: this.app}));
|
||||
}
|
||||
}
|
||||
27
desktop/flipper-server-core/src/comms/ClientConnection.tsx
Normal file
27
desktop/flipper-server-core/src/comms/ClientConnection.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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 {ClientResponseType} from 'flipper-common';
|
||||
|
||||
export enum ConnectionStatus {
|
||||
ERROR = 'error',
|
||||
CLOSED = 'closed',
|
||||
CONNECTED = 'connected',
|
||||
NOT_CONNECTED = 'not_connected',
|
||||
CONNECTING = 'connecting',
|
||||
}
|
||||
|
||||
export type ConnectionStatusChange = (status: ConnectionStatus) => void;
|
||||
|
||||
export interface ClientConnection {
|
||||
subscribeToEvents(subscriber: ConnectionStatusChange): void;
|
||||
close(): void;
|
||||
send(data: any): void;
|
||||
sendExpectResponse(data: any): Promise<ClientResponseType>;
|
||||
}
|
||||
200
desktop/flipper-server-core/src/comms/ServerAdapter.tsx
Normal file
200
desktop/flipper-server-core/src/comms/ServerAdapter.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 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 {
|
||||
CertificateExchangeMedium,
|
||||
SecureServerConfig,
|
||||
} from '../utils/CertificateProvider';
|
||||
import {ClientConnection} from './ClientConnection';
|
||||
import {transformCertificateExchangeMediumToType} from './Utilities';
|
||||
import {ClientDescription, ClientQuery} from 'flipper-common';
|
||||
|
||||
/**
|
||||
* ClientCsrQuery defines a client query with CSR
|
||||
* information.
|
||||
*/
|
||||
export type ClientCsrQuery = {
|
||||
csr?: string | undefined;
|
||||
csr_path?: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* SecureClientQuery combines a ClientQuery with
|
||||
* ClientCsrQuery. It also adds medium information.
|
||||
*/
|
||||
export type SecureClientQuery = ClientQuery &
|
||||
ClientCsrQuery & {medium: 1 /*FS*/ | 2 /*WWW*/ | 3 /*NONE*/ | undefined};
|
||||
|
||||
/**
|
||||
* Defines an interface for events triggered by a running server interacting
|
||||
* with a client.
|
||||
*/
|
||||
export interface ServerEventsListener {
|
||||
/**
|
||||
* Server started and listening at the specified port.
|
||||
* @param port The port in which the server is listening to.
|
||||
*/
|
||||
onListening(port: number): void;
|
||||
/**
|
||||
* An insecure connection attempt has been made by a client. At this
|
||||
* point, a connection should be already be available but needs to be
|
||||
* validated by the server.
|
||||
* @param clientQuery A ClientQuery instance containing metadata about
|
||||
* the client e.g. OS, device, app, etc.
|
||||
*/
|
||||
onConnectionAttempt(clientQuery: ClientQuery): void;
|
||||
/**
|
||||
* A TLS connection attempt has been made by a client. At this
|
||||
* point, a connection should be already be available but needs to be
|
||||
* validated by the server.
|
||||
* @param clientQuery A SecureClientQuery instance containing metadata about
|
||||
* the client and CSR information as exchanged on the previously
|
||||
* established insecure connection.
|
||||
*/
|
||||
onSecureConnectionAttempt(clientQuery: SecureClientQuery): void;
|
||||
/**
|
||||
* CSR received by the server and needs to be processed. If successfully
|
||||
* processed, it should return a generated device identifier.
|
||||
* @param unsanitizedCSR CSR as sent by the client, will need to be sanitized
|
||||
* before usage.
|
||||
* @param clientQuery A ClientQuery instance containing metadata about
|
||||
* the client e.g. OS, device, app, etc.
|
||||
* @param appDirectory App directory in which to deploy the CA and client
|
||||
* certificates.
|
||||
* @param medium Certificate exchange medium type e.g. FS_ACCESS, WWW.
|
||||
*/
|
||||
onProcessCSR(
|
||||
unsanitizedCSR: string,
|
||||
clientQuery: ClientQuery,
|
||||
appDirectory: string,
|
||||
medium: CertificateExchangeMedium,
|
||||
): Promise<{deviceId: string}>;
|
||||
/**
|
||||
* A secure connection has been established with a validated client.
|
||||
* A promise to a Client instance needs to be returned.
|
||||
* @param clientQuery A SecureClientQuery instance containing metadata about
|
||||
* the client and CSR information as exchanged on the previously
|
||||
* established insecure connection.
|
||||
* @param clientConnection A valid client connection.
|
||||
*/
|
||||
onConnectionCreated(
|
||||
clientQuery: SecureClientQuery,
|
||||
clientConnection: ClientConnection,
|
||||
): Promise<ClientDescription>;
|
||||
/**
|
||||
* A connection with a client has been closed.
|
||||
* @param id The client identifier.
|
||||
*/
|
||||
onConnectionClosed(id: string): void;
|
||||
/**
|
||||
* An error has occurred.
|
||||
* @param error An Error instance.
|
||||
*/
|
||||
onError(error: Error): void;
|
||||
/**
|
||||
* A message was received for a specif client
|
||||
* // TODO: payload should become JSON
|
||||
*/
|
||||
onClientMessage(clientId: string, payload: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the base class to be used by any server implementation e.g.
|
||||
* RSocket, WebSocket, etc.
|
||||
*/
|
||||
abstract class ServerAdapter {
|
||||
listener: ServerEventsListener;
|
||||
|
||||
constructor(listener: ServerEventsListener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start and bind server to the specified port.
|
||||
* @param port A port number.
|
||||
* @param sslConfig An optional SSL configuration to be used for
|
||||
* TLS servers.
|
||||
*/
|
||||
abstract start(
|
||||
port: number,
|
||||
sslConfig?: SecureServerConfig,
|
||||
): Promise<boolean>;
|
||||
/**
|
||||
* Stop the server.
|
||||
*/
|
||||
abstract stop(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Handle a message received over an insecure connection. The only
|
||||
* supported message is to sign certificates.
|
||||
* @param clientQuery A ClientQuery instance containing metadata about
|
||||
* the client e.g. OS, device, app, etc.
|
||||
* @param rawData Raw data as sent by the client.
|
||||
* @returns The response to be sent back to the client. If the received
|
||||
* request is to sign a certificate and no errors were found, the response
|
||||
* should contain the device identifier to use by the client.
|
||||
*/
|
||||
async _onHandleUntrustedMessage(
|
||||
clientQuery: ClientQuery,
|
||||
rawData: any,
|
||||
): Promise<string | undefined> {
|
||||
// OSS's older Client SDK might not send medium information.
|
||||
// This is not an issue for internal FB users, as Flipper release
|
||||
// is insync with client SDK through launcher.
|
||||
|
||||
const message: {
|
||||
method: 'signCertificate';
|
||||
csr: string;
|
||||
destination: string;
|
||||
medium: number | undefined;
|
||||
} = rawData;
|
||||
|
||||
console.info(
|
||||
`[conn] Connection attempt: ${clientQuery.app} on ${clientQuery.device}, medium: ${message.medium}, cert: ${message.destination}`,
|
||||
clientQuery,
|
||||
);
|
||||
|
||||
if (message.method === 'signCertificate') {
|
||||
console.debug('CSR received from device', 'server');
|
||||
|
||||
const {csr, destination, medium} = message;
|
||||
|
||||
console.info(
|
||||
`[conn] Starting certificate exchange: ${clientQuery.app} on ${clientQuery.device}`,
|
||||
);
|
||||
try {
|
||||
const result = await this.listener.onProcessCSR(
|
||||
csr,
|
||||
clientQuery,
|
||||
destination,
|
||||
transformCertificateExchangeMediumToType(medium),
|
||||
);
|
||||
|
||||
console.info(
|
||||
`[conn] Exchanged certificate: ${clientQuery.app} on ${result.deviceId}`,
|
||||
);
|
||||
const response = JSON.stringify({
|
||||
deviceId: result.deviceId,
|
||||
});
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[conn] Failed to exchange certificate with ${clientQuery.app} on ${
|
||||
clientQuery.device || clientQuery.device_id
|
||||
}`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export default ServerAdapter;
|
||||
494
desktop/flipper-server-core/src/comms/ServerController.tsx
Normal file
494
desktop/flipper-server-core/src/comms/ServerController.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
* 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 {CertificateExchangeMedium} from '../utils/CertificateProvider';
|
||||
import {Logger} from 'flipper-common';
|
||||
import {
|
||||
ClientDescription,
|
||||
ClientQuery,
|
||||
isTest,
|
||||
GK,
|
||||
buildClientId,
|
||||
} from 'flipper-common';
|
||||
import CertificateProvider from '../utils/CertificateProvider';
|
||||
import {ClientConnection, ConnectionStatus} from './ClientConnection';
|
||||
import {UninitializedClient} from 'flipper-common';
|
||||
import {reportPlatformFailures} from 'flipper-common';
|
||||
import {EventEmitter} from 'events';
|
||||
import invariant from 'invariant';
|
||||
import DummyDevice from '../devices/DummyDevice';
|
||||
import {
|
||||
appNameWithUpdateHint,
|
||||
transformCertificateExchangeMediumToType,
|
||||
} from './Utilities';
|
||||
import ServerAdapter, {
|
||||
SecureClientQuery,
|
||||
ServerEventsListener,
|
||||
} from './ServerAdapter';
|
||||
import {
|
||||
createBrowserServer,
|
||||
createServer,
|
||||
TransportType,
|
||||
} from './ServerFactory';
|
||||
import {FlipperServerImpl} from '../FlipperServerImpl';
|
||||
import {
|
||||
getServerPortsConfig,
|
||||
getFlipperServerConfig,
|
||||
} from '../FlipperServerConfig';
|
||||
|
||||
type ClientInfo = {
|
||||
connection: ClientConnection | null | undefined;
|
||||
client: ClientDescription;
|
||||
};
|
||||
|
||||
type ClientCsrQuery = {
|
||||
csr?: string | undefined;
|
||||
csr_path?: string | undefined;
|
||||
};
|
||||
|
||||
declare interface ServerController {
|
||||
on(event: 'error', callback: (err: Error) => void): this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsible of creating and managing the actual underlying servers:
|
||||
* - Insecure (used for certificate exchange)
|
||||
* - Secure (used for secure communication between the client and server)
|
||||
* - Browser (only ever used between Desktop and a local Browser)
|
||||
*
|
||||
* Additionally, it manages client connections.
|
||||
*/
|
||||
class ServerController extends EventEmitter implements ServerEventsListener {
|
||||
connections: Map<string, ClientInfo>;
|
||||
|
||||
initialized: Promise<void> | null;
|
||||
secureServer: Promise<ServerAdapter> | null;
|
||||
insecureServer: Promise<ServerAdapter> | null;
|
||||
altSecureServer: Promise<ServerAdapter> | null;
|
||||
altInsecureServer: Promise<ServerAdapter> | null;
|
||||
browserServer: Promise<ServerAdapter> | null;
|
||||
|
||||
certificateProvider: CertificateProvider;
|
||||
connectionTracker: ConnectionTracker;
|
||||
|
||||
flipperServer: FlipperServerImpl;
|
||||
|
||||
timeHandlers: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
constructor(flipperServer: FlipperServerImpl) {
|
||||
super();
|
||||
this.flipperServer = flipperServer;
|
||||
this.connections = new Map();
|
||||
this.certificateProvider = new CertificateProvider(
|
||||
this,
|
||||
this.logger,
|
||||
getFlipperServerConfig(),
|
||||
);
|
||||
this.connectionTracker = new ConnectionTracker(this.logger);
|
||||
this.secureServer = null;
|
||||
this.insecureServer = null;
|
||||
this.altSecureServer = null;
|
||||
this.altInsecureServer = null;
|
||||
this.browserServer = null;
|
||||
this.initialized = null;
|
||||
}
|
||||
|
||||
onClientMessage(clientId: string, payload: string): void {
|
||||
this.flipperServer.emit('client-message', {
|
||||
id: clientId,
|
||||
message: payload,
|
||||
});
|
||||
}
|
||||
|
||||
get logger(): Logger {
|
||||
return this.flipperServer.logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the secure server configuration and starts any necessary servers.
|
||||
* Initialisation is complete once the initialized promise is fullfilled at
|
||||
* which point Flipper is accepting connections.
|
||||
*/
|
||||
init() {
|
||||
if (isTest()) {
|
||||
throw new Error('Spawing new server is not supported in test');
|
||||
}
|
||||
const {insecure, secure} = getServerPortsConfig().serverPorts;
|
||||
|
||||
this.initialized = this.certificateProvider
|
||||
.loadSecureServerConfig()
|
||||
.then((options) => {
|
||||
console.info('[conn] secure server listening at port: ', secure);
|
||||
this.secureServer = createServer(secure, this, options);
|
||||
if (GK.get('flipper_websocket_server')) {
|
||||
const {secure: altSecure} = getServerPortsConfig().altServerPorts;
|
||||
console.info(
|
||||
'[conn] secure server (ws) listening at port: ',
|
||||
altSecure,
|
||||
);
|
||||
this.altSecureServer = createServer(
|
||||
altSecure,
|
||||
this,
|
||||
options,
|
||||
TransportType.WebSocket,
|
||||
);
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
console.info('[conn] insecure server listening at port: ', insecure);
|
||||
this.insecureServer = createServer(insecure, this);
|
||||
if (GK.get('flipper_websocket_server')) {
|
||||
const {insecure: altInsecure} = getServerPortsConfig().altServerPorts;
|
||||
console.info(
|
||||
'[conn] insecure server (ws) listening at port: ',
|
||||
altInsecure,
|
||||
);
|
||||
this.altInsecureServer = createServer(
|
||||
altInsecure,
|
||||
this,
|
||||
undefined,
|
||||
TransportType.WebSocket,
|
||||
);
|
||||
}
|
||||
return;
|
||||
});
|
||||
|
||||
if (GK.get('comet_enable_flipper_connection')) {
|
||||
this.browserServer = createBrowserServer(8333, this);
|
||||
}
|
||||
|
||||
reportPlatformFailures(this.initialized, 'initializeServer');
|
||||
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* If initialized, it stops any started servers.
|
||||
*/
|
||||
async close() {
|
||||
if (this.initialized && (await this.initialized)) {
|
||||
await Promise.all([
|
||||
this.insecureServer && (await this.insecureServer).stop(),
|
||||
this.secureServer && (await this.secureServer).stop(),
|
||||
this.altInsecureServer && (await this.altInsecureServer).stop(),
|
||||
this.altSecureServer && (await this.altSecureServer).stop(),
|
||||
this.browserServer && (await this.browserServer).stop(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
onConnectionCreated(
|
||||
clientQuery: SecureClientQuery,
|
||||
clientConnection: ClientConnection,
|
||||
): Promise<ClientDescription> {
|
||||
const {app, os, device, device_id, sdk_version, csr, csr_path, medium} =
|
||||
clientQuery;
|
||||
const transformedMedium = transformCertificateExchangeMediumToType(medium);
|
||||
console.info(
|
||||
`[conn] Connection established: ${app} on ${device_id}. Medium ${medium}. CSR: ${csr_path}`,
|
||||
clientQuery,
|
||||
);
|
||||
return this.addConnection(
|
||||
clientConnection,
|
||||
{
|
||||
app,
|
||||
os,
|
||||
device,
|
||||
device_id,
|
||||
sdk_version,
|
||||
medium: transformedMedium,
|
||||
},
|
||||
{csr, csr_path},
|
||||
);
|
||||
}
|
||||
|
||||
onConnectionClosed(clientId: string) {
|
||||
this.removeConnection(clientId);
|
||||
}
|
||||
|
||||
onListening(port: number): void {
|
||||
this.emit('listening', port);
|
||||
}
|
||||
|
||||
onSecureConnectionAttempt(clientQuery: SecureClientQuery): void {
|
||||
this.logger.track('usage', 'trusted-request-handler-called', clientQuery);
|
||||
|
||||
const {os, app, device_id} = clientQuery;
|
||||
// without these checks, the user might see a connection timeout error instead, which would be much harder to track down
|
||||
if (os === 'iOS' && !getFlipperServerConfig().enableIOS) {
|
||||
console.error(
|
||||
`Refusing connection from ${app} on ${device_id}, since iOS support is disabled in settings`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (os === 'Android' && !getFlipperServerConfig().enableAndroid) {
|
||||
console.error(
|
||||
`Refusing connection from ${app} on ${device_id}, since Android support is disabled in settings`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.connectionTracker.logConnectionAttempt(clientQuery);
|
||||
|
||||
if (this.timeHandlers.get(clientQueryToKey(clientQuery))) {
|
||||
clearTimeout(this.timeHandlers.get(clientQueryToKey(clientQuery))!);
|
||||
}
|
||||
|
||||
const transformedMedium = transformCertificateExchangeMediumToType(
|
||||
clientQuery.medium,
|
||||
);
|
||||
if (transformedMedium === 'WWW' || transformedMedium === 'NONE') {
|
||||
this.flipperServer.registerDevice(
|
||||
new DummyDevice(
|
||||
this.flipperServer,
|
||||
clientQuery.device_id,
|
||||
clientQuery.app +
|
||||
(transformedMedium === 'WWW' ? ' Server Exchanged' : ''),
|
||||
clientQuery.os,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onConnectionAttempt(clientQuery: ClientQuery): void {
|
||||
this.logger.track('usage', 'untrusted-request-handler-called', clientQuery);
|
||||
this.connectionTracker.logConnectionAttempt(clientQuery);
|
||||
|
||||
const client: UninitializedClient = {
|
||||
os: clientQuery.os,
|
||||
deviceName: clientQuery.device,
|
||||
appName: appNameWithUpdateHint(clientQuery),
|
||||
};
|
||||
this.emit('start-client-setup', client);
|
||||
}
|
||||
|
||||
onProcessCSR(
|
||||
unsanitizedCSR: string,
|
||||
clientQuery: ClientQuery,
|
||||
appDirectory: string,
|
||||
medium: CertificateExchangeMedium,
|
||||
): Promise<{deviceId: string}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
reportPlatformFailures(
|
||||
this.certificateProvider.processCertificateSigningRequest(
|
||||
unsanitizedCSR,
|
||||
clientQuery.os,
|
||||
appDirectory,
|
||||
medium,
|
||||
),
|
||||
'processCertificateSigningRequest',
|
||||
)
|
||||
.then((response) => {
|
||||
const client: UninitializedClient = {
|
||||
os: clientQuery.os,
|
||||
deviceName: clientQuery.device,
|
||||
appName: appNameWithUpdateHint(clientQuery),
|
||||
};
|
||||
// TODO: if multiple clients are establishing a connection
|
||||
// at the same time, then this unresponsive timeout can potentially
|
||||
// lead to errors. For example, client A starts connectiving followed
|
||||
// by client B. Client B timeHandler will override client A, thus, if
|
||||
// client A takes longer, then the unresponsive timeout will not be called
|
||||
// for it.
|
||||
this.timeHandlers.set(
|
||||
clientQueryToKey(clientQuery),
|
||||
setTimeout(() => {
|
||||
this.emit('client-unresponsive-error', {
|
||||
client,
|
||||
medium,
|
||||
deviceID: response.deviceId,
|
||||
});
|
||||
}, 30 * 1000),
|
||||
);
|
||||
|
||||
resolve(response);
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onError(error: Error): void {
|
||||
this.emit('error', error);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Client and sets the underlying connection.
|
||||
* @param connection A client connection to communicate between server and client.
|
||||
* @param query The client query created from the initial handshake.
|
||||
* @param csrQuery The CSR query which contains CSR related information.
|
||||
*/
|
||||
async addConnection(
|
||||
connection: ClientConnection,
|
||||
query: ClientQuery & {medium: CertificateExchangeMedium},
|
||||
csrQuery: ClientCsrQuery,
|
||||
): Promise<ClientDescription> {
|
||||
invariant(query, 'expected query');
|
||||
|
||||
// try to get id by comparing giving `csr` to file from `csr_path`
|
||||
// otherwise, use given device_id
|
||||
const {csr_path, csr} = csrQuery;
|
||||
|
||||
// For Android, device id might change
|
||||
if (csr_path && csr && query.os === 'Android') {
|
||||
const app_name = await this.certificateProvider.extractAppNameFromCSR(
|
||||
csr,
|
||||
);
|
||||
// TODO: allocate new object, kept now as is to keep changes minimal
|
||||
(query as any).device_id =
|
||||
await this.certificateProvider.getTargetDeviceId(
|
||||
query.os,
|
||||
app_name,
|
||||
csr_path,
|
||||
csr,
|
||||
);
|
||||
console.info(
|
||||
`[conn] Detected ${app_name} on ${query.device_id} in certificate`,
|
||||
query,
|
||||
csrQuery,
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: allocate new object, kept now as is to keep changes minimal
|
||||
(query as any).app = appNameWithUpdateHint(query);
|
||||
|
||||
const id = buildClientId({
|
||||
app: query.app,
|
||||
os: query.os,
|
||||
device: query.device,
|
||||
device_id: query.device_id,
|
||||
});
|
||||
console.info(
|
||||
`[conn] Matching device for ${query.app} on ${query.device_id}...`,
|
||||
query,
|
||||
csrQuery,
|
||||
);
|
||||
|
||||
const client: ClientDescription = {
|
||||
id,
|
||||
query,
|
||||
};
|
||||
|
||||
const info = {
|
||||
client,
|
||||
connection: connection,
|
||||
};
|
||||
|
||||
console.info(
|
||||
`[conn] Initializing client ${query.app} on ${query.device_id}...`,
|
||||
query,
|
||||
csrQuery,
|
||||
);
|
||||
|
||||
connection.subscribeToEvents((status: ConnectionStatus) => {
|
||||
if (
|
||||
status === ConnectionStatus.CLOSED ||
|
||||
status === ConnectionStatus.ERROR
|
||||
) {
|
||||
this.onConnectionClosed(client.id);
|
||||
}
|
||||
});
|
||||
|
||||
console.debug(
|
||||
`[conn] Device client initialized: ${id}.`,
|
||||
'server',
|
||||
query,
|
||||
csrQuery,
|
||||
);
|
||||
|
||||
/* If a device gets disconnected without being cleaned up properly,
|
||||
* Flipper won't be aware until it attempts to reconnect.
|
||||
* When it does we need to terminate the zombie connection.
|
||||
*/
|
||||
if (this.connections.has(id)) {
|
||||
const connectionInfo = this.connections.get(id);
|
||||
if (connectionInfo) {
|
||||
if (
|
||||
connectionInfo.connection &&
|
||||
connectionInfo.connection !== connection
|
||||
) {
|
||||
connectionInfo.connection.close();
|
||||
this.removeConnection(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.connections.set(id, info);
|
||||
this.flipperServer.emit('client-connected', client);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
attachFakeClient(client: ClientDescription) {
|
||||
this.connections.set(client.id, {
|
||||
client,
|
||||
connection: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a client connection by disconnecting it, if still connected
|
||||
* and then deleting it from the tracked connections.
|
||||
* @param id The client connection identifier.
|
||||
*/
|
||||
removeConnection(id: string) {
|
||||
const info = this.connections.get(id);
|
||||
if (info) {
|
||||
console.info(
|
||||
`[conn] Disconnected: ${info.client.query.app} on ${info.client.query.device_id}.`,
|
||||
info.client.query,
|
||||
);
|
||||
this.flipperServer.emit('client-disconnected', {id});
|
||||
this.connections.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ConnectionTracker {
|
||||
timeWindowMillis = 20 * 1000;
|
||||
connectionProblemThreshold = 4;
|
||||
|
||||
// "${device}.${app}" -> [timestamp1, timestamp2...]
|
||||
connectionAttempts: Map<string, Array<number>> = new Map();
|
||||
logger: Logger;
|
||||
|
||||
constructor(logger: Logger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
logConnectionAttempt(client: ClientQuery) {
|
||||
const key = `${client.os}-${client.device}-${client.app}`;
|
||||
const time = Date.now();
|
||||
let entry = this.connectionAttempts.get(key) || [];
|
||||
entry.push(time);
|
||||
entry = entry.filter((t) => t >= time - this.timeWindowMillis);
|
||||
|
||||
this.connectionAttempts.set(key, entry);
|
||||
if (entry.length >= this.connectionProblemThreshold) {
|
||||
console.error(
|
||||
`[conn] Connection loop detected with ${key}. Connected ${
|
||||
this.connectionProblemThreshold
|
||||
} times within ${this.timeWindowMillis / 1000}s.`,
|
||||
'server',
|
||||
client,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ServerController;
|
||||
|
||||
function clientQueryToKey(clientQuery: ClientQuery): string {
|
||||
return `${clientQuery.app}/${clientQuery.os}/${clientQuery.device}/${clientQuery.device_id}`;
|
||||
}
|
||||
88
desktop/flipper-server-core/src/comms/ServerFactory.tsx
Normal file
88
desktop/flipper-server-core/src/comms/ServerFactory.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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 {SecureServerConfig} from '../utils/CertificateProvider';
|
||||
import ServerAdapter, {ServerEventsListener} from './ServerAdapter';
|
||||
import ServerRSocket from './ServerRSocket';
|
||||
import ServerWebSocket from './ServerWebSocket';
|
||||
import ServerWebSocketBrowser from './ServerWebSocketBrowser';
|
||||
|
||||
export enum TransportType {
|
||||
RSocket,
|
||||
WebSocket,
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a server to be used by Flipper. The created server will be set into
|
||||
* the promise once it has started and bound to the specified port.
|
||||
* @param port A port number in which to listen for incoming connections.
|
||||
* @param listener An object implementing the ServerEventsListener interface.
|
||||
* @param sslConfig An SSL configuration for TLS servers.
|
||||
*/
|
||||
export function createServer(
|
||||
port: number,
|
||||
listener: ServerEventsListener,
|
||||
sslConfig?: SecureServerConfig,
|
||||
transportType: TransportType = TransportType.RSocket,
|
||||
): Promise<ServerAdapter> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server =
|
||||
transportType === TransportType.RSocket
|
||||
? new ServerRSocket(listener)
|
||||
: new ServerWebSocket(listener);
|
||||
server
|
||||
.start(port, sslConfig)
|
||||
.then((started) => {
|
||||
if (started) {
|
||||
resolve(server);
|
||||
} else {
|
||||
reject(
|
||||
new Error(`An error occurred whilst trying
|
||||
to start the server listening at port ${port}`),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a server to be used by Flipper to allow Browser connections.
|
||||
* The protocol is slightly different for Browser connections hence a different
|
||||
* factory method. The created server will be set into the promise
|
||||
* once it has started and bound to the specified port.
|
||||
* @param port A port number in which to listen for incoming connections.
|
||||
* @param listener An object implementing the ServerEventsListener interface.
|
||||
* @returns
|
||||
*/
|
||||
export function createBrowserServer(
|
||||
port: number,
|
||||
listener: ServerEventsListener,
|
||||
): Promise<ServerAdapter> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = new ServerWebSocketBrowser(listener);
|
||||
server
|
||||
.start(port)
|
||||
.then((started) => {
|
||||
if (started) {
|
||||
resolve(server);
|
||||
} else {
|
||||
reject(
|
||||
new Error(`An error occurred whilst trying
|
||||
to start the server listening at port ${port}`),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
286
desktop/flipper-server-core/src/comms/ServerRSocket.tsx
Normal file
286
desktop/flipper-server-core/src/comms/ServerRSocket.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* 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 {SecureServerConfig} from '../utils/CertificateProvider';
|
||||
import ServerAdapter, {
|
||||
SecureClientQuery,
|
||||
ServerEventsListener,
|
||||
} from './ServerAdapter';
|
||||
import tls from 'tls';
|
||||
import net, {Socket} from 'net';
|
||||
import {RSocketServer} from 'rsocket-core';
|
||||
import RSocketTCPServer from 'rsocket-tcp-server';
|
||||
import {Payload, ReactiveSocket, Responder} from 'rsocket-types';
|
||||
import {Single} from 'rsocket-flowable';
|
||||
import {
|
||||
ClientConnection,
|
||||
ConnectionStatusChange,
|
||||
ConnectionStatus,
|
||||
} from './ClientConnection';
|
||||
import {
|
||||
ClientDescription,
|
||||
ClientQuery,
|
||||
ClientResponseType,
|
||||
} from 'flipper-common';
|
||||
|
||||
/**
|
||||
* RSocket based server. RSocket uses its own protocol for communication between
|
||||
* client and server.
|
||||
*/
|
||||
class ServerRSocket extends ServerAdapter {
|
||||
rawServer_: RSocketServer<any, any> | null | undefined;
|
||||
constructor(listener: ServerEventsListener) {
|
||||
super(listener);
|
||||
this.rawServer_ = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the server bound to the specified port. It configures
|
||||
* the RSocket server factory and request handler based on the optional
|
||||
* sslConfig argument.
|
||||
*/
|
||||
start(port: number, sslConfig?: SecureServerConfig): Promise<boolean> {
|
||||
const self = this;
|
||||
return new Promise((resolve, reject) => {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let rawServer: RSocketServer<any, any> | undefined;
|
||||
const serverFactory = (onConnect: (socket: Socket) => void) => {
|
||||
const transportServer = sslConfig
|
||||
? tls.createServer(sslConfig, (socket) => {
|
||||
onConnect(socket);
|
||||
})
|
||||
: net.createServer(onConnect);
|
||||
transportServer
|
||||
.on('error', (err) => {
|
||||
self.listener.onError(err);
|
||||
console.error(`Error opening server on port ${port}`, 'server');
|
||||
reject(err);
|
||||
})
|
||||
.on('listening', () => {
|
||||
console.debug(
|
||||
`${
|
||||
sslConfig ? 'Secure' : 'Certificate'
|
||||
} server started on port ${port}`,
|
||||
'server',
|
||||
);
|
||||
self.listener.onListening(port);
|
||||
self.rawServer_ = rawServer;
|
||||
resolve(true);
|
||||
});
|
||||
return transportServer;
|
||||
};
|
||||
rawServer = new RSocketServer({
|
||||
getRequestHandler: sslConfig
|
||||
? this._trustedRequestHandler
|
||||
: this._untrustedRequestHandler,
|
||||
transport: new RSocketTCPServer({
|
||||
port: port,
|
||||
serverFactory: serverFactory,
|
||||
}),
|
||||
});
|
||||
rawServer && rawServer.start();
|
||||
});
|
||||
}
|
||||
|
||||
stop(): Promise<void> {
|
||||
if (this.rawServer_) {
|
||||
return Promise.resolve(this.rawServer_.stop());
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming connection request over TLS.
|
||||
* @param socket Underlying socket connection.
|
||||
* @param payload Payload or message received.
|
||||
* @returns Returns a valid RSocket responder which will handle further
|
||||
* communication from the client.
|
||||
*/
|
||||
_trustedRequestHandler = (
|
||||
socket: ReactiveSocket<string, any>,
|
||||
payload: Payload<string, any>,
|
||||
): Partial<Responder<string, any>> => {
|
||||
if (!payload.data) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const clientQuery: SecureClientQuery = JSON.parse(payload.data);
|
||||
this.listener.onSecureConnectionAttempt(clientQuery);
|
||||
console.info(
|
||||
`[conn] Secure rsocket connection attempt: ${clientQuery.app} on ${clientQuery.device_id}. Medium ${clientQuery.medium}. CSR: ${clientQuery.csr_path}`,
|
||||
);
|
||||
|
||||
const clientConnection: ClientConnection = {
|
||||
subscribeToEvents(subscriber: ConnectionStatusChange): void {
|
||||
socket.connectionStatus().subscribe({
|
||||
onNext(payload) {
|
||||
let status = ConnectionStatus.CONNECTED;
|
||||
|
||||
if (payload.kind == 'ERROR') status = ConnectionStatus.ERROR;
|
||||
else if (payload.kind == 'CLOSED') status = ConnectionStatus.CLOSED;
|
||||
else if (payload.kind == 'CONNECTED')
|
||||
status = ConnectionStatus.CONNECTED;
|
||||
else if (payload.kind == 'NOT_CONNECTED')
|
||||
status = ConnectionStatus.NOT_CONNECTED;
|
||||
else if (payload.kind == 'CONNECTING')
|
||||
status = ConnectionStatus.CONNECTING;
|
||||
|
||||
subscriber(status);
|
||||
},
|
||||
onSubscribe(subscription) {
|
||||
subscription.request(Number.MAX_SAFE_INTEGER);
|
||||
},
|
||||
onError(payload) {
|
||||
console.error('[client] connection status error ', payload);
|
||||
},
|
||||
});
|
||||
},
|
||||
close(): void {
|
||||
socket.close();
|
||||
},
|
||||
send(data: any): void {
|
||||
socket.fireAndForget({data: JSON.stringify(data)});
|
||||
},
|
||||
sendExpectResponse(data: any): Promise<any> {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
socket
|
||||
.requestResponse({
|
||||
data: JSON.stringify(data),
|
||||
})
|
||||
.subscribe({
|
||||
onComplete: (payload: Payload<any, any>) => {
|
||||
const response: ClientResponseType = JSON.parse(payload.data);
|
||||
response.length = payload.data.length;
|
||||
resolve(response);
|
||||
},
|
||||
onError: (e) => {
|
||||
reject(e);
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
let resolvedClient: ClientDescription | undefined;
|
||||
const client: Promise<ClientDescription> =
|
||||
this.listener.onConnectionCreated(clientQuery, clientConnection);
|
||||
client
|
||||
.then((client) => {
|
||||
console.info(
|
||||
`[conn] Client connected: ${clientQuery.app} on ${clientQuery.device_id}. Medium ${clientQuery.medium}. CSR: ${clientQuery.csr_path}`,
|
||||
);
|
||||
resolvedClient = client;
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('[conn] Failed to resolve new client', e);
|
||||
});
|
||||
|
||||
return {
|
||||
fireAndForget: (payload: {data: string}) => {
|
||||
if (resolvedClient) {
|
||||
this.listener.onClientMessage(resolvedClient.id, payload.data);
|
||||
} else {
|
||||
client &&
|
||||
client
|
||||
.then((client) => {
|
||||
this.listener.onClientMessage(client.id, payload.data);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Could not deliver message: ', e);
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle an incoming connection request over an insecure connection.
|
||||
* @param socket Underlying socket connection.
|
||||
* @param payload Payload or message received.
|
||||
* @returns Returns a valid RSocket responder which will handle further
|
||||
* communication from the client.
|
||||
*/
|
||||
_untrustedRequestHandler = (
|
||||
_socket: ReactiveSocket<string, any>,
|
||||
payload: Payload<string, any>,
|
||||
): Partial<Responder<string, any>> => {
|
||||
if (!payload.data) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const clientQuery: ClientQuery = JSON.parse(payload.data);
|
||||
this.listener.onConnectionAttempt(clientQuery);
|
||||
|
||||
return {
|
||||
requestResponse: (
|
||||
payload: Payload<string, any>,
|
||||
): Single<Payload<string, any>> => {
|
||||
if (typeof payload.data !== 'string') {
|
||||
return new Single((_) => {});
|
||||
}
|
||||
|
||||
let rawData: any;
|
||||
try {
|
||||
rawData = JSON.parse(payload.data);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[conn] Invalid JSON: ${payload.data}`,
|
||||
'clientMessage',
|
||||
'server',
|
||||
);
|
||||
return new Single((_) => {});
|
||||
}
|
||||
|
||||
return new Single((subscriber) => {
|
||||
subscriber.onSubscribe(undefined);
|
||||
this._onHandleUntrustedMessage(clientQuery, rawData)
|
||||
.then((response) => {
|
||||
subscriber.onComplete({
|
||||
data: response,
|
||||
metadata: '',
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
subscriber.onError(err);
|
||||
});
|
||||
});
|
||||
},
|
||||
// Can probably refactor this out
|
||||
// Leaving this here for a while for backwards compatibility,
|
||||
// but for up to date SDKs it will no longer used.
|
||||
fireAndForget: (payload: Payload<string, any>) => {
|
||||
if (typeof payload.data !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
let rawData: any;
|
||||
try {
|
||||
rawData = JSON.parse(payload.data);
|
||||
} catch (err) {
|
||||
console.error(`Invalid JSON: ${payload.data}`, 'server');
|
||||
return;
|
||||
}
|
||||
|
||||
if (rawData && rawData.method === 'signCertificate') {
|
||||
console.debug('CSR received from device', 'server');
|
||||
this._onHandleUntrustedMessage(clientQuery, rawData)
|
||||
.then((_) => {})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
'[conn] Unable to process CSR, failed with error.',
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default ServerRSocket;
|
||||
300
desktop/flipper-server-core/src/comms/ServerWebSocket.tsx
Normal file
300
desktop/flipper-server-core/src/comms/ServerWebSocket.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* 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 ServerWebSocketBase from './ServerWebSocketBase';
|
||||
import WebSocket from 'ws';
|
||||
import ws from 'ws';
|
||||
import {SecureClientQuery, ServerEventsListener} from './ServerAdapter';
|
||||
import querystring from 'querystring';
|
||||
import {
|
||||
ClientConnection,
|
||||
ConnectionStatus,
|
||||
ConnectionStatusChange,
|
||||
} from './ClientConnection';
|
||||
import {IncomingMessage} from 'http';
|
||||
import {
|
||||
ClientDescription,
|
||||
ClientErrorType,
|
||||
ClientQuery,
|
||||
DeviceOS,
|
||||
} from 'flipper-common';
|
||||
|
||||
/**
|
||||
* WebSocket-based server.
|
||||
*/
|
||||
class ServerWebSocket extends ServerWebSocketBase {
|
||||
constructor(listener: ServerEventsListener) {
|
||||
super(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Client verification is not necessary. The connected client has
|
||||
* already been verified using its certificate signed by the server.
|
||||
* @returns
|
||||
*/
|
||||
verifyClient(): ws.VerifyClientCallbackSync {
|
||||
return (_info: {origin: string; req: IncomingMessage; secure: boolean}) => {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection has been established between the server and a client. Only ever used for
|
||||
* certificate exchange.
|
||||
* @param ws An active WebSocket.
|
||||
* @param message Incoming request message.
|
||||
*/
|
||||
onConnection(ws: WebSocket, message: any): void {
|
||||
const query = querystring.decode(message.url.split('?')[1]);
|
||||
const clientQuery = this._parseClientQuery(query);
|
||||
if (!clientQuery) {
|
||||
console.warn(
|
||||
'[conn] Unable to extract the client query from the request URL.',
|
||||
);
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[conn] Insecure websocket connection attempt: ${clientQuery.app} on ${clientQuery.device_id}.`,
|
||||
clientQuery,
|
||||
);
|
||||
this.listener.onConnectionAttempt(clientQuery);
|
||||
|
||||
ws.on('message', async (message: any) => {
|
||||
const json = JSON.parse(message.toString());
|
||||
const response = await this._onHandleUntrustedMessage(clientQuery, json);
|
||||
if (response) {
|
||||
ws.send(response);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A secure connection has been established between the server and a client. Once a client
|
||||
* has a valid certificate, it can use a secure connection with Flipper and start exchanging
|
||||
* messages.
|
||||
* @param _ws An active WebSocket.
|
||||
* @param message Incoming request message.
|
||||
*/
|
||||
onSecureConnection(ws: WebSocket, message: any): void {
|
||||
const query = querystring.decode(message.url.split('?')[1]);
|
||||
const clientQuery = this._parseSecureClientQuery(query);
|
||||
if (!clientQuery) {
|
||||
console.warn(
|
||||
'[conn] Unable to extract the client query from the request URL.',
|
||||
);
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
console.info(
|
||||
`[conn] Secure websocket connection attempt: ${clientQuery.app} on ${clientQuery.device_id}. Medium ${clientQuery.medium}. CSR: ${clientQuery.csr_path}`,
|
||||
clientQuery,
|
||||
);
|
||||
this.listener.onSecureConnectionAttempt(clientQuery);
|
||||
|
||||
const pendingRequests: Map<
|
||||
number,
|
||||
{
|
||||
resolve: (data: any) => void;
|
||||
reject: (err: Error) => void;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
const clientConnection: ClientConnection = {
|
||||
subscribeToEvents(subscriber: ConnectionStatusChange): void {
|
||||
ws.on('close', () => subscriber(ConnectionStatus.CLOSED));
|
||||
ws.on('error', () => subscriber(ConnectionStatus.ERROR));
|
||||
},
|
||||
close(): void {
|
||||
ws.close();
|
||||
},
|
||||
send(data: any): void {
|
||||
ws.send(JSON.stringify(data));
|
||||
},
|
||||
sendExpectResponse(data: any): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingRequests.set(data.id, {reject, resolve});
|
||||
ws.send(JSON.stringify(data));
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
let resolvedClient: ClientDescription | undefined;
|
||||
const client: Promise<ClientDescription> =
|
||||
this.listener.onConnectionCreated(clientQuery, clientConnection);
|
||||
client
|
||||
.then((client) => (resolvedClient = client))
|
||||
.catch((e) => {
|
||||
console.error(
|
||||
`[conn] Failed to resolve client ${clientQuery.app} on ${clientQuery.device_id} medium ${clientQuery.medium}`,
|
||||
e,
|
||||
);
|
||||
});
|
||||
|
||||
ws.on('message', (message: any) => {
|
||||
let json: any | undefined;
|
||||
try {
|
||||
json = JSON.parse(message);
|
||||
} catch (err) {
|
||||
console.warn(`Invalid JSON: ${message}`, 'clientMessage');
|
||||
return;
|
||||
}
|
||||
|
||||
const data: {
|
||||
id?: number;
|
||||
success?: Object | undefined;
|
||||
error?: ClientErrorType | undefined;
|
||||
} = json;
|
||||
|
||||
if (data.hasOwnProperty('id') && data.id !== undefined) {
|
||||
const callbacks = pendingRequests.get(data.id);
|
||||
if (!callbacks) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRequests.delete(data.id);
|
||||
|
||||
if (data.success) {
|
||||
callbacks.resolve && callbacks.resolve(data);
|
||||
} else if (data.error) {
|
||||
callbacks.reject && callbacks.reject(data.error);
|
||||
}
|
||||
} else {
|
||||
if (resolvedClient) {
|
||||
this.listener.onClientMessage(resolvedClient.id, message);
|
||||
} else {
|
||||
client &&
|
||||
client
|
||||
.then((client) => {
|
||||
this.listener.onClientMessage(client.id, message);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn(
|
||||
'Could not deliver message, client did not resolve. ',
|
||||
e,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a string as being one of those defined as valid OS.
|
||||
* @param str An input string.
|
||||
*/
|
||||
private isOS(str: string): str is DeviceOS {
|
||||
return (
|
||||
str === 'iOS' ||
|
||||
str === 'Android' ||
|
||||
str === 'Metro' ||
|
||||
str === 'Windows' ||
|
||||
str === 'MacOS'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and extract a ClientQuery instance from a message. The ClientQuery
|
||||
* data will be contained in the message url query string.
|
||||
* @param message An incoming web socket message.
|
||||
*/
|
||||
private _parseClientQuery(
|
||||
query: querystring.ParsedUrlQuery,
|
||||
): ClientQuery | undefined {
|
||||
/** Any required arguments to construct a ClientQuery come
|
||||
* embedded in the query string.
|
||||
*/
|
||||
let device_id: string | undefined;
|
||||
if (typeof query.device_id === 'string') {
|
||||
device_id = query.device_id;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
let device: string | undefined;
|
||||
if (typeof query.device === 'string') {
|
||||
device = query.device;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
let app: string | undefined;
|
||||
if (typeof query.app === 'string') {
|
||||
app = query.app;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
let os: DeviceOS | undefined;
|
||||
if (typeof query.os === 'string' && this.isOS(query.os)) {
|
||||
os = query.os;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientQuery: ClientQuery = {
|
||||
device_id,
|
||||
device,
|
||||
app,
|
||||
os,
|
||||
};
|
||||
|
||||
if (typeof query.sdk_version === 'string') {
|
||||
const sdk_version = parseInt(query.sdk_version, 10);
|
||||
if (sdk_version) {
|
||||
// TODO: allocate new object, kept now as is to keep changes minimal
|
||||
(clientQuery as any).sdk_version = sdk_version;
|
||||
}
|
||||
}
|
||||
|
||||
return clientQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and extract a SecureClientQuery instance from a message. The ClientQuery
|
||||
* data will be contained in the message url query string.
|
||||
* @param message An incoming web socket message.
|
||||
*/
|
||||
private _parseSecureClientQuery(
|
||||
query: querystring.ParsedUrlQuery,
|
||||
): SecureClientQuery | undefined {
|
||||
/** Any required arguments to construct a SecureClientQuery come
|
||||
* embedded in the query string.
|
||||
*/
|
||||
const clientQuery = this._parseClientQuery(query);
|
||||
if (!clientQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
let csr: string | undefined;
|
||||
if (typeof query.csr === 'string') {
|
||||
const buffer = Buffer.from(query.csr, 'base64');
|
||||
if (buffer) {
|
||||
csr = buffer.toString('ascii');
|
||||
}
|
||||
}
|
||||
|
||||
let csr_path: string | undefined;
|
||||
if (typeof query.csr_path === 'string') {
|
||||
csr_path = query.csr_path;
|
||||
}
|
||||
|
||||
let medium: number | undefined;
|
||||
if (typeof query.medium === 'string') {
|
||||
medium = parseInt(query.medium, 10);
|
||||
}
|
||||
if (medium !== undefined && (medium < 1 || medium > 3)) {
|
||||
throw new Error('Unsupported exchange medium: ' + medium);
|
||||
}
|
||||
return {...clientQuery, csr, csr_path, medium: medium as any};
|
||||
}
|
||||
}
|
||||
|
||||
export default ServerWebSocket;
|
||||
113
desktop/flipper-server-core/src/comms/ServerWebSocketBase.tsx
Normal file
113
desktop/flipper-server-core/src/comms/ServerWebSocketBase.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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 {IncomingMessage} from 'http';
|
||||
import {SecureServerConfig} from '../utils/CertificateProvider';
|
||||
import ServerAdapter, {ServerEventsListener} from './ServerAdapter';
|
||||
import ws from 'ws';
|
||||
import WebSocket from 'ws';
|
||||
import https from 'https';
|
||||
import http from 'http';
|
||||
|
||||
/**
|
||||
* It serves as a base class for WebSocket based servers. It delegates the 'connection'
|
||||
* event to subclasses as a customisation point.
|
||||
*/
|
||||
abstract class ServerWebSocketBase extends ServerAdapter {
|
||||
rawServer_: ws.Server | null;
|
||||
constructor(listener: ServerEventsListener) {
|
||||
super(listener);
|
||||
this.rawServer_ = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket client verification. Usually used to validate the origin.
|
||||
*
|
||||
* Base implementation simply returns true, but this can be overriden by subclasses
|
||||
* that require verification.
|
||||
*
|
||||
* @returns Return true if the client was successfully verified, otherwise
|
||||
* returns false.
|
||||
*/
|
||||
verifyClient(): ws.VerifyClientCallbackSync {
|
||||
return (_info: {origin: string; req: IncomingMessage; secure: boolean}) => {
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
start(port: number, sslConfig?: SecureServerConfig): Promise<boolean> {
|
||||
const self = this;
|
||||
return new Promise((resolve, reject) => {
|
||||
let server: http.Server | https.Server | undefined;
|
||||
if (sslConfig) {
|
||||
server = https.createServer({
|
||||
key: sslConfig.key,
|
||||
cert: sslConfig.cert,
|
||||
ca: sslConfig.ca,
|
||||
// Client to provide a certificate to authenticate.
|
||||
requestCert: sslConfig.requestCert,
|
||||
// As specified as "true", so no unauthenticated traffic
|
||||
// will make it to the specified route specified
|
||||
rejectUnauthorized: sslConfig.rejectUnauthorized,
|
||||
});
|
||||
} else {
|
||||
server = http.createServer();
|
||||
}
|
||||
|
||||
const handleRequest = sslConfig
|
||||
? self.onSecureConnection
|
||||
: self.onConnection;
|
||||
|
||||
const rawServer = new WebSocket.Server({
|
||||
server,
|
||||
verifyClient: this.verifyClient(),
|
||||
});
|
||||
rawServer.on('connection', (ws: WebSocket, message: any) => {
|
||||
handleRequest.apply(self, [ws, message]);
|
||||
});
|
||||
rawServer.on('error', (_ws: WebSocket, error: any) => {
|
||||
console.warn('[conn] Server found connection error: ' + error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
if (server) {
|
||||
server.listen(port, () => {
|
||||
console.debug(
|
||||
`${
|
||||
sslConfig ? 'Secure' : 'Certificate'
|
||||
} server started on port ${port}`,
|
||||
'server',
|
||||
);
|
||||
self.listener.onListening(port);
|
||||
self.rawServer_ = rawServer;
|
||||
resolve(true);
|
||||
});
|
||||
} else {
|
||||
reject(new Error(`Unable to start server at port ${port}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
stop(): Promise<void> {
|
||||
if (this.rawServer_) {
|
||||
return Promise.resolve(this.rawServer_.close());
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
onConnection(_ws: WebSocket, _message: any): void {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
onSecureConnection(_ws: WebSocket, _message: any): void {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
export default ServerWebSocketBase;
|
||||
192
desktop/flipper-server-core/src/comms/ServerWebSocketBrowser.tsx
Normal file
192
desktop/flipper-server-core/src/comms/ServerWebSocketBrowser.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 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 ServerWebSocketBase from './ServerWebSocketBase';
|
||||
import WebSocket from 'ws';
|
||||
import querystring from 'querystring';
|
||||
import {BrowserClientFlipperConnection} from './BrowserClientFlipperConnection';
|
||||
import {ServerEventsListener} from './ServerAdapter';
|
||||
import ws from 'ws';
|
||||
import {IncomingMessage} from 'http';
|
||||
import {ClientDescription, ClientQuery} from 'flipper-common';
|
||||
import {getFlipperServerConfig} from '../FlipperServerConfig';
|
||||
|
||||
/**
|
||||
* WebSocket-based server which uses a connect/disconnect handshake over an insecure channel.
|
||||
*/
|
||||
class ServerWebSocketBrowser extends ServerWebSocketBase {
|
||||
constructor(listener: ServerEventsListener) {
|
||||
super(listener);
|
||||
}
|
||||
|
||||
verifyClient(): ws.VerifyClientCallbackSync {
|
||||
return (info: {origin: string; req: IncomingMessage; secure: boolean}) => {
|
||||
const ok = getFlipperServerConfig().validWebSocketOrigins.some(
|
||||
(validPrefix) => info.origin.startsWith(validPrefix),
|
||||
);
|
||||
if (!ok) {
|
||||
console.warn(
|
||||
`[conn] Refused webSocket connection from ${info.origin} (secure: ${info.secure})`,
|
||||
);
|
||||
}
|
||||
return ok;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection has been established between the server and a client.
|
||||
* @param ws An active WebSocket.
|
||||
* @param message Incoming request message.
|
||||
*/
|
||||
onConnection(ws: WebSocket, message: any): void {
|
||||
const clients: {
|
||||
[app: string]: Promise<ClientDescription>;
|
||||
} = {};
|
||||
|
||||
/**
|
||||
* Any required arguments to construct a ClientQuery come
|
||||
* embedded in the query string.
|
||||
*/
|
||||
const query = querystring.decode(message.url.split('?')[1]);
|
||||
const deviceId: string =
|
||||
typeof query.deviceId === 'string' ? query.deviceId : 'webbrowser';
|
||||
const device =
|
||||
typeof query.device === 'string' ? query.device : 'WebSocket';
|
||||
|
||||
const clientQuery: ClientQuery = {
|
||||
device_id: deviceId,
|
||||
device,
|
||||
app: device,
|
||||
os: 'MacOS', // TODO: not hardcoded! Use host device?
|
||||
};
|
||||
|
||||
console.info(
|
||||
`[conn] Local websocket connection attempt: ${clientQuery.app} on ${clientQuery.device_id}.`,
|
||||
);
|
||||
this.listener.onConnectionAttempt(clientQuery);
|
||||
|
||||
const cleanup = () => {
|
||||
Object.values(clients).map((p) =>
|
||||
p.then((c) => this.listener.onConnectionClosed(c.id)),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to the 'message' event. Initially, a handshake should take place in the form of a
|
||||
* 'connect' message. Once received, a client connection will be established and registered. This
|
||||
* is followed by another subscription to the 'message' event, again. Effectively, two listeners
|
||||
* are now attached to that event. The former will continue to check for 'connect' and 'disconnect'
|
||||
* messages. The latter will deliver messages to the client.
|
||||
*/
|
||||
ws.on('message', (rawMessage: any) => {
|
||||
let message: any | undefined;
|
||||
try {
|
||||
message = JSON.parse(rawMessage.toString());
|
||||
} catch (error) {
|
||||
// Throws a SyntaxError exception if the string to parse is not valid JSON.
|
||||
console.log('Received message is not valid.', error);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'connect': {
|
||||
const app = message.app;
|
||||
const plugins = message.plugins;
|
||||
|
||||
const clientConnection = new BrowserClientFlipperConnection(
|
||||
ws,
|
||||
app,
|
||||
plugins,
|
||||
);
|
||||
|
||||
const extendedClientQuery = {...clientQuery, medium: 3 as const};
|
||||
extendedClientQuery.sdk_version = plugins == null ? 4 : 1;
|
||||
|
||||
console.info(
|
||||
`[conn] Local websocket connection established: ${clientQuery.app} on ${clientQuery.device_id}.`,
|
||||
);
|
||||
|
||||
let resolvedClient: ClientDescription | null = null;
|
||||
this.listener.onSecureConnectionAttempt(extendedClientQuery);
|
||||
const client: Promise<ClientDescription> =
|
||||
this.listener.onConnectionCreated(
|
||||
extendedClientQuery,
|
||||
clientConnection,
|
||||
);
|
||||
client
|
||||
.then((client) => {
|
||||
console.info(
|
||||
`[conn] Client connected: ${clientQuery.app} on ${clientQuery.device_id}.`,
|
||||
);
|
||||
resolvedClient = client;
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(
|
||||
'[conn] Failed to connect client over webSocket',
|
||||
e,
|
||||
);
|
||||
});
|
||||
|
||||
clients[app] = client;
|
||||
|
||||
ws.on('message', (m: any) => {
|
||||
let parsed: any | undefined;
|
||||
try {
|
||||
parsed = JSON.parse(m.toString());
|
||||
} catch (error) {
|
||||
// Throws a SyntaxError exception if the string to parse is not valid JSON.
|
||||
console.info('[conn] Received message is not valid.', error);
|
||||
return;
|
||||
}
|
||||
// non-null payload id means response to prev request, it's handled in connection
|
||||
if (parsed.app === app && parsed.payload?.id == null) {
|
||||
const message = JSON.stringify(parsed.payload);
|
||||
if (resolvedClient) {
|
||||
this.listener.onClientMessage(resolvedClient.id, message);
|
||||
} else {
|
||||
client
|
||||
.then((c) => this.listener.onClientMessage(c.id, message))
|
||||
.catch((e) => {
|
||||
console.warn(
|
||||
'Could not deliver message, client did not resolve: ' +
|
||||
app,
|
||||
e,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'disconnect': {
|
||||
const app = message.app;
|
||||
(clients[app] || Promise.resolve())
|
||||
.then((c) => {
|
||||
this.listener.onConnectionClosed(c.id);
|
||||
delete clients[app];
|
||||
})
|
||||
.catch((_) => {});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** Close event from the existing client connection. */
|
||||
ws.on('close', () => {
|
||||
cleanup();
|
||||
});
|
||||
/** Error event from the existing client connection. */
|
||||
ws.on('error', (error) => {
|
||||
console.warn('[conn] Server found connection error: ' + error);
|
||||
cleanup();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default ServerWebSocketBrowser;
|
||||
50
desktop/flipper-server-core/src/comms/Utilities.tsx
Normal file
50
desktop/flipper-server-core/src/comms/Utilities.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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 {ClientQuery} from 'flipper-common';
|
||||
import {CertificateExchangeMedium} from '../utils/CertificateProvider';
|
||||
|
||||
/**
|
||||
* Transforms the certificate exchange medium type as number to the
|
||||
* CertificateExchangeMedium type.
|
||||
* @param medium A number representing the certificate exchange medium type.
|
||||
*/
|
||||
export function transformCertificateExchangeMediumToType(
|
||||
medium: number | undefined,
|
||||
): CertificateExchangeMedium {
|
||||
switch (medium) {
|
||||
case undefined:
|
||||
case 1:
|
||||
return 'FS_ACCESS';
|
||||
case 2:
|
||||
return 'WWW';
|
||||
case 3:
|
||||
return 'NONE';
|
||||
default:
|
||||
throw new Error('Unknown Certificate exchange medium: ' + medium);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the app name from a ClientQuery instance. In most cases it should be
|
||||
* the app name as given in the query. On Android, and for old SDK versions (<3) it
|
||||
* will returned the app name suffixed by '(Outdated SDK)'.
|
||||
*
|
||||
* Reason is, in previous version (<3), app may not appear in correct device
|
||||
* section because it refers to the name given by client which is not fixed
|
||||
* for android emulators, so it is indicated as outdated so that developers
|
||||
* might want to update SDK to get rid of this connection swap problem
|
||||
* @param query A ClientQuery object.
|
||||
*/
|
||||
export function appNameWithUpdateHint(query: ClientQuery): string {
|
||||
if (query.os === 'Android' && (!query.sdk_version || query.sdk_version < 3)) {
|
||||
return query.app + ' (Outdated SDK)';
|
||||
}
|
||||
return query.app;
|
||||
}
|
||||
31
desktop/flipper-server-core/src/devices/DummyDevice.tsx
Normal file
31
desktop/flipper-server-core/src/devices/DummyDevice.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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 {DeviceOS} from 'flipper-common';
|
||||
import {FlipperServerImpl} from '../FlipperServerImpl';
|
||||
import {ServerDevice} from './ServerDevice';
|
||||
|
||||
/**
|
||||
* Use this device when you do not have the actual uuid of the device. For example, it is currently used in the case when, we do certificate exchange through WWW mode. In this mode we do not know the device id of the app and we generate a fake one.
|
||||
*/
|
||||
export default class DummyDevice extends ServerDevice {
|
||||
constructor(
|
||||
flipperServer: FlipperServerImpl,
|
||||
serial: string,
|
||||
title: string,
|
||||
os: DeviceOS,
|
||||
) {
|
||||
super(flipperServer, {
|
||||
serial,
|
||||
deviceType: 'dummy',
|
||||
title,
|
||||
os,
|
||||
});
|
||||
}
|
||||
}
|
||||
86
desktop/flipper-server-core/src/devices/ServerDevice.tsx
Normal file
86
desktop/flipper-server-core/src/devices/ServerDevice.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 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 {DeviceDescription, DeviceLogEntry} from 'flipper-common';
|
||||
import {FlipperServerImpl} from '../FlipperServerImpl';
|
||||
|
||||
export abstract class ServerDevice {
|
||||
readonly info: DeviceDescription;
|
||||
readonly flipperServer: FlipperServerImpl;
|
||||
connected = true;
|
||||
|
||||
constructor(flipperServer: FlipperServerImpl, info: DeviceDescription) {
|
||||
this.flipperServer = flipperServer;
|
||||
this.info = info;
|
||||
}
|
||||
|
||||
get serial(): string {
|
||||
return this.info.serial;
|
||||
}
|
||||
|
||||
addLogEntry(entry: DeviceLogEntry) {
|
||||
this.flipperServer.emit('device-log', {
|
||||
serial: this.serial,
|
||||
entry,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The device might have no active connection
|
||||
*/
|
||||
disconnect(): void {
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
startLogging() {
|
||||
// to be subclassed
|
||||
}
|
||||
|
||||
stopLogging() {
|
||||
// to be subclassed
|
||||
}
|
||||
|
||||
async screenshotAvailable(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
screenshot(): Promise<Buffer> {
|
||||
return Promise.reject(
|
||||
new Error('No screenshot support for current device'),
|
||||
);
|
||||
}
|
||||
|
||||
async screenCaptureAvailable(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async startScreenCapture(_destination: string): Promise<void> {
|
||||
throw new Error('startScreenCapture not implemented on BaseDevice ');
|
||||
}
|
||||
|
||||
async stopScreenCapture(): Promise<string> {
|
||||
throw new Error('stopScreenCapture not implemented on BaseDevice ');
|
||||
}
|
||||
|
||||
async executeShell(_command: string): Promise<string> {
|
||||
throw new Error('executeShell not implemented on BaseDevice');
|
||||
}
|
||||
|
||||
async forwardPort(_local: string, _remote: string): Promise<boolean> {
|
||||
throw new Error('forwardPort not implemented on BaseDevice');
|
||||
}
|
||||
|
||||
async clearLogs(): Promise<void> {
|
||||
// no-op on most devices
|
||||
}
|
||||
|
||||
async navigateToLocation(_location: string) {
|
||||
throw new Error('navigateLocation not implemented on BaseDevice');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 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 adb, {Client as ADBClient, PullTransfer} from 'adbkit';
|
||||
import {Priority, Reader} from 'adbkit-logcat';
|
||||
import {createWriteStream} from 'fs';
|
||||
import type {DeviceLogLevel, DeviceType} from 'flipper-common';
|
||||
import which from 'which';
|
||||
import {spawn} from 'child_process';
|
||||
import {dirname, join} from 'path';
|
||||
import {DeviceSpec} from 'flipper-plugin-lib';
|
||||
import {ServerDevice} from '../ServerDevice';
|
||||
import {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||
|
||||
const DEVICE_RECORDING_DIR = '/sdcard/flipper_recorder';
|
||||
|
||||
export default class AndroidDevice extends ServerDevice {
|
||||
adb: ADBClient;
|
||||
pidAppMapping: {[key: number]: string} = {};
|
||||
private recordingProcess?: Promise<string>;
|
||||
reader?: Reader;
|
||||
|
||||
constructor(
|
||||
flipperServer: FlipperServerImpl,
|
||||
serial: string,
|
||||
deviceType: DeviceType,
|
||||
title: string,
|
||||
adb: ADBClient,
|
||||
abiList: Array<string>,
|
||||
sdkVersion: string,
|
||||
specs: DeviceSpec[] = [],
|
||||
) {
|
||||
super(flipperServer, {
|
||||
serial,
|
||||
deviceType,
|
||||
title,
|
||||
os: 'Android',
|
||||
icon: 'mobile',
|
||||
specs,
|
||||
abiList,
|
||||
sdkVersion,
|
||||
});
|
||||
this.adb = adb;
|
||||
}
|
||||
|
||||
startLogging() {
|
||||
this.adb
|
||||
.openLogcat(this.serial, {clear: true})
|
||||
.then((reader) => {
|
||||
this.reader = reader;
|
||||
reader
|
||||
.on('entry', (entry) => {
|
||||
let type: DeviceLogLevel = 'unknown';
|
||||
if (entry.priority === Priority.VERBOSE) {
|
||||
type = 'verbose';
|
||||
}
|
||||
if (entry.priority === Priority.DEBUG) {
|
||||
type = 'debug';
|
||||
}
|
||||
if (entry.priority === Priority.INFO) {
|
||||
type = 'info';
|
||||
}
|
||||
if (entry.priority === Priority.WARN) {
|
||||
type = 'warn';
|
||||
}
|
||||
if (entry.priority === Priority.ERROR) {
|
||||
type = 'error';
|
||||
}
|
||||
if (entry.priority === Priority.FATAL) {
|
||||
type = 'fatal';
|
||||
}
|
||||
|
||||
this.addLogEntry({
|
||||
tag: entry.tag,
|
||||
pid: entry.pid,
|
||||
tid: entry.tid,
|
||||
message: entry.message,
|
||||
date: entry.date,
|
||||
type,
|
||||
});
|
||||
})
|
||||
.on('end', () => {
|
||||
if (this.reader) {
|
||||
// logs didn't stop gracefully
|
||||
setTimeout(() => {
|
||||
if (this.connected) {
|
||||
console.warn(
|
||||
`Log stream broken: ${this.serial} - restarting`,
|
||||
);
|
||||
this.startLogging();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
})
|
||||
.on('error', (e) => {
|
||||
console.warn('Failed to read from adb logcat: ', e);
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn('Failed to open log stream: ', e);
|
||||
});
|
||||
}
|
||||
|
||||
stopLogging() {
|
||||
this.reader?.end();
|
||||
this.reader = undefined;
|
||||
}
|
||||
|
||||
reverse(ports: number[]): Promise<void> {
|
||||
return Promise.all(
|
||||
ports.map((port) =>
|
||||
this.adb.reverse(this.serial, `tcp:${port}`, `tcp:${port}`),
|
||||
),
|
||||
).then(() => {
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
clearLogs(): Promise<void> {
|
||||
return this.executeShellOrDie(['logcat', '-c']);
|
||||
}
|
||||
|
||||
async navigateToLocation(location: string) {
|
||||
const shellCommand = `am start ${encodeURI(location)}`;
|
||||
this.adb.shell(this.serial, shellCommand);
|
||||
}
|
||||
|
||||
async screenshot(): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.adb
|
||||
.screencap(this.serial)
|
||||
.then((stream) => {
|
||||
const chunks: Array<Buffer> = [];
|
||||
stream
|
||||
.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
.once('end', () => {
|
||||
resolve(Buffer.concat(chunks));
|
||||
})
|
||||
.once('error', reject);
|
||||
})
|
||||
.catch((e) => {
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async screenCaptureAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await this.executeShellOrDie(
|
||||
`[ ! -f /system/bin/screenrecord ] && echo "File does not exist"`,
|
||||
);
|
||||
return true;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async executeShell(command: string): Promise<string> {
|
||||
return await this.adb
|
||||
.shell(this.serial, command)
|
||||
.then(adb.util.readAll)
|
||||
.then((output: Buffer) => output.toString().trim());
|
||||
}
|
||||
|
||||
private async executeShellOrDie(command: string | string[]): Promise<void> {
|
||||
const output = await this.adb
|
||||
.shell(this.serial, command)
|
||||
.then(adb.util.readAll)
|
||||
.then((output: Buffer) => output.toString().trim());
|
||||
if (output) {
|
||||
throw new Error(output);
|
||||
}
|
||||
}
|
||||
|
||||
private async getSdkVersion(): Promise<number> {
|
||||
return await this.adb
|
||||
.shell(this.serial, 'getprop ro.build.version.sdk')
|
||||
.then(adb.util.readAll)
|
||||
.then((output) => Number(output.toString().trim()));
|
||||
}
|
||||
|
||||
private async isValidFile(filePath: string): Promise<boolean> {
|
||||
const sdkVersion = await this.getSdkVersion();
|
||||
const fileSize = await this.adb
|
||||
.shell(this.serial, `ls -l "${filePath}"`)
|
||||
.then(adb.util.readAll)
|
||||
.then((output: Buffer) => output.toString().trim().split(' '))
|
||||
.then((x) => x.filter(Boolean))
|
||||
.then((x) => (sdkVersion > 23 ? Number(x[4]) : Number(x[3])));
|
||||
|
||||
return fileSize > 0;
|
||||
}
|
||||
|
||||
async startScreenCapture(destination: string) {
|
||||
await this.executeShellOrDie(
|
||||
`mkdir -p "${DEVICE_RECORDING_DIR}" && echo -n > "${DEVICE_RECORDING_DIR}/.nomedia"`,
|
||||
);
|
||||
const recordingLocation = `${DEVICE_RECORDING_DIR}/video.mp4`;
|
||||
let newSize: string | undefined;
|
||||
try {
|
||||
const sizeString = (
|
||||
await adb.util.readAll(await this.adb.shell(this.serial, 'wm size'))
|
||||
).toString();
|
||||
const size = sizeString.split(' ').slice(-1).pop()?.split('x');
|
||||
if (size && size.length === 2) {
|
||||
const width = parseInt(size[0], 10);
|
||||
const height = parseInt(size[1], 10);
|
||||
if (width > height) {
|
||||
newSize = '1280x720';
|
||||
} else {
|
||||
newSize = '720x1280';
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error while getting device size', err);
|
||||
}
|
||||
const sizeArg = newSize ? `--size ${newSize}` : '';
|
||||
const cmd = `screenrecord ${sizeArg} "${recordingLocation}"`;
|
||||
this.recordingProcess = this.adb
|
||||
.shell(this.serial, cmd)
|
||||
.then(adb.util.readAll)
|
||||
.then(async (output) => {
|
||||
const isValid = await this.isValidFile(recordingLocation);
|
||||
if (!isValid) {
|
||||
const outputMessage = output.toString().trim();
|
||||
throw new Error(
|
||||
'Recording was not properly started: \n' + outputMessage,
|
||||
);
|
||||
}
|
||||
})
|
||||
.then(
|
||||
(_) =>
|
||||
new Promise(async (resolve, reject) => {
|
||||
const stream: PullTransfer = await this.adb.pull(
|
||||
this.serial,
|
||||
recordingLocation,
|
||||
);
|
||||
stream.on('end', resolve as () => void);
|
||||
stream.on('error', reject);
|
||||
stream.pipe(createWriteStream(destination, {autoClose: true}), {
|
||||
end: true,
|
||||
});
|
||||
}),
|
||||
)
|
||||
.then((_) => destination);
|
||||
|
||||
return this.recordingProcess.then((_) => {});
|
||||
}
|
||||
|
||||
async stopScreenCapture(): Promise<string> {
|
||||
const {recordingProcess} = this;
|
||||
if (!recordingProcess) {
|
||||
return Promise.reject(new Error('Recording was not properly started'));
|
||||
}
|
||||
await this.adb.shell(this.serial, `pkill -l2 screenrecord`);
|
||||
const destination = await recordingProcess;
|
||||
this.recordingProcess = undefined;
|
||||
return destination;
|
||||
}
|
||||
|
||||
async forwardPort(local: string, remote: string): Promise<boolean> {
|
||||
return this.adb.forward(this.serial, local, remote);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.recordingProcess) {
|
||||
this.stopScreenCapture();
|
||||
}
|
||||
super.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export async function launchEmulator(name: string, coldBoot: boolean = false) {
|
||||
// On Linux, you must run the emulator from the directory it's in because
|
||||
// reasons ...
|
||||
return which('emulator')
|
||||
.catch(() =>
|
||||
join(
|
||||
process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || '',
|
||||
'tools',
|
||||
'emulator',
|
||||
),
|
||||
)
|
||||
.then((emulatorPath) => {
|
||||
if (emulatorPath) {
|
||||
const child = spawn(
|
||||
emulatorPath,
|
||||
[`@${name}`, ...(coldBoot ? ['-no-snapshot-load'] : [])],
|
||||
{
|
||||
detached: true,
|
||||
cwd: dirname(emulatorPath),
|
||||
},
|
||||
);
|
||||
child.stderr.on('data', (data) => {
|
||||
console.warn(`Android emulator stderr: ${data}`);
|
||||
});
|
||||
child.on('error', (e) => console.warn('Android emulator error:', e));
|
||||
} else {
|
||||
throw new Error('Could not get emulator path');
|
||||
}
|
||||
})
|
||||
.catch((e) => console.error('Android emulator startup failed:', e));
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 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 {DeviceType} from 'flipper-plugin-lib';
|
||||
import AndroidDevice from './AndroidDevice';
|
||||
import {Client as ADBClient} from 'adbkit';
|
||||
import {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||
|
||||
export default class KaiOSDevice extends AndroidDevice {
|
||||
constructor(
|
||||
flipperServer: FlipperServerImpl,
|
||||
serial: string,
|
||||
deviceType: DeviceType,
|
||||
title: string,
|
||||
adb: ADBClient,
|
||||
abiList: Array<string>,
|
||||
sdkVersion: string,
|
||||
) {
|
||||
super(flipperServer, serial, deviceType, title, adb, abiList, sdkVersion, [
|
||||
'KaiOS',
|
||||
]);
|
||||
}
|
||||
|
||||
async screenCaptureAvailable() {
|
||||
// The default way of capturing screenshots through adb does not seem to work
|
||||
// There is a way of getting a screenshot through KaiOS dev tools though
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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 adbConfig from '../adbConfig';
|
||||
|
||||
test('get host and port from ADB_SERVER_SOCKET', () => {
|
||||
process.env.ANDROID_ADB_SERVER_PORT = undefined;
|
||||
process.env.ADB_SERVER_SOCKET = 'tcp:127.0.0.1:5037';
|
||||
const {port, host} = adbConfig();
|
||||
expect(port).toBe(5037);
|
||||
expect(host).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
test('get IPv6 address from ADB_SERVER_SOCKET', () => {
|
||||
process.env.ANDROID_ADB_SERVER_PORT = undefined;
|
||||
process.env.ADB_SERVER_SOCKET = 'tcp::::1:5037';
|
||||
const {host} = adbConfig();
|
||||
expect(host).toBe(':::1');
|
||||
});
|
||||
|
||||
test('get port from ANDROID_ADB_SERVER_PORT', () => {
|
||||
process.env.ANDROID_ADB_SERVER_PORT = '1337';
|
||||
process.env.ADB_SERVER_SOCKET = undefined;
|
||||
const {port} = adbConfig();
|
||||
expect(port).toBe(1337);
|
||||
});
|
||||
|
||||
test('prefer ADB_SERVER_SOCKET over ANDROID_ADB_SERVER_PORT', () => {
|
||||
process.env.ANDROID_ADB_SERVER_PORT = '1337';
|
||||
process.env.ADB_SERVER_SOCKET = 'tcp:127.0.0.1:5037';
|
||||
const {port} = adbConfig();
|
||||
expect(port).toBe(5037);
|
||||
});
|
||||
|
||||
test('have defaults', () => {
|
||||
process.env.ANDROID_ADB_SERVER_PORT = undefined;
|
||||
process.env.ADB_SERVER_SOCKET = undefined;
|
||||
const {port, host} = adbConfig();
|
||||
expect(port).toBe(5037);
|
||||
expect(host).toBe('localhost');
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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 {reportPlatformFailures} from 'flipper-common';
|
||||
import {execFile} from 'promisify-child-process';
|
||||
import adbConfig from './adbConfig';
|
||||
import adbkit, {Client} from 'adbkit';
|
||||
import path from 'path';
|
||||
|
||||
let instance: Promise<Client>;
|
||||
|
||||
type Config = {
|
||||
androidHome: string;
|
||||
};
|
||||
|
||||
export function getAdbClient(config: Config): Promise<Client> {
|
||||
if (!instance) {
|
||||
instance = reportPlatformFailures(createClient(config), 'createADBClient');
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/* Adbkit will attempt to start the adb server if it's not already running,
|
||||
however, it sometimes fails with ENOENT errors. So instead, we start it
|
||||
manually before requesting a client. */
|
||||
async function createClient(config: Config): Promise<Client> {
|
||||
const androidHome = config.androidHome;
|
||||
const adbPath = path.resolve(androidHome, 'platform-tools', 'adb');
|
||||
return reportPlatformFailures<Client>(
|
||||
execFile(adbPath, ['start-server']).then(() =>
|
||||
adbkit.createClient(adbConfig()),
|
||||
),
|
||||
'createADBClient.shell',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 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 {parseEnvironmentVariableAsNumber} from '../../utils/environmentVariables';
|
||||
|
||||
export default () => {
|
||||
let port = parseEnvironmentVariableAsNumber(
|
||||
'ANDROID_ADB_SERVER_PORT',
|
||||
5037,
|
||||
) as number;
|
||||
|
||||
let host = 'localhost';
|
||||
|
||||
const socket = (process.env.ADB_SERVER_SOCKET || '').trim();
|
||||
if (socket && socket.length > 0) {
|
||||
const match = socket.match(/^(tcp:)(\S+):(\d+)/);
|
||||
if (match && match.length === 4) {
|
||||
host = match[2];
|
||||
port = parseInt(match[3], 10);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
host,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 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 {UnsupportedError} from 'flipper-common';
|
||||
import adbkit, {Client} from 'adbkit';
|
||||
|
||||
const allowedAppNameRegex = /^[\w.-]+$/;
|
||||
const appNotApplicationRegex = /not an application/;
|
||||
const appNotDebuggableRegex = /debuggable/;
|
||||
const operationNotPermittedRegex = /not permitted/;
|
||||
const logTag = 'androidContainerUtility';
|
||||
|
||||
export type AppName = string;
|
||||
export type Command = string;
|
||||
export type FilePath = string;
|
||||
export type FileContent = string;
|
||||
|
||||
export async function push(
|
||||
client: Client,
|
||||
deviceId: string,
|
||||
app: string,
|
||||
filepath: string,
|
||||
contents: string,
|
||||
): Promise<void> {
|
||||
validateAppName(app);
|
||||
validateFilePath(filepath);
|
||||
validateFileContent(contents);
|
||||
return await _push(client, deviceId, app, filepath, contents);
|
||||
}
|
||||
|
||||
export async function pull(
|
||||
client: Client,
|
||||
deviceId: string,
|
||||
app: string,
|
||||
path: string,
|
||||
): Promise<string> {
|
||||
validateAppName(app);
|
||||
validateFilePath(path);
|
||||
return await _pull(client, deviceId, app, path);
|
||||
}
|
||||
|
||||
function validateAppName(app: string): void {
|
||||
if (!app.match(allowedAppNameRegex)) {
|
||||
throw new Error(`Disallowed run-as user: ${app}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFilePath(filePath: string): void {
|
||||
if (filePath.match(/[']/)) {
|
||||
throw new Error(`Disallowed escaping filepath: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFileContent(content: string): void {
|
||||
if (content.match(/["]/)) {
|
||||
throw new Error(`Disallowed escaping file content: ${content}`);
|
||||
}
|
||||
}
|
||||
|
||||
enum RunAsErrorCode {
|
||||
NotAnApp = 1,
|
||||
NotDebuggable = 2,
|
||||
}
|
||||
|
||||
class RunAsError extends Error {
|
||||
code: RunAsErrorCode;
|
||||
|
||||
constructor(code: RunAsErrorCode, message?: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
function _push(
|
||||
client: Client,
|
||||
deviceId: string,
|
||||
app: AppName,
|
||||
filename: FilePath,
|
||||
contents: FileContent,
|
||||
): Promise<void> {
|
||||
console.debug(`Deploying ${filename} to ${deviceId}:${app}`, logTag);
|
||||
// TODO: this is sensitive to escaping issues, can we leverage client.push instead?
|
||||
// https://www.npmjs.com/package/adbkit#pushing-a-file-to-all-connected-devices
|
||||
const command = `echo "${contents}" > '${filename}' && chmod 644 '${filename}'`;
|
||||
return executeCommandAsApp(client, deviceId, app, command)
|
||||
.then((_) => undefined)
|
||||
.catch((error) => {
|
||||
if (error instanceof RunAsError) {
|
||||
// Fall back to running the command directly. This will work if adb is running as root.
|
||||
executeCommandWithSu(client, deviceId, app, command, error);
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
function _pull(
|
||||
client: Client,
|
||||
deviceId: string,
|
||||
app: AppName,
|
||||
path: FilePath,
|
||||
): Promise<string> {
|
||||
const command = `cat '${path}'`;
|
||||
return executeCommandAsApp(client, deviceId, app, command).catch((error) => {
|
||||
if (error instanceof RunAsError) {
|
||||
// Fall back to running the command directly. This will work if adb is running as root.
|
||||
return executeCommandWithSu(client, deviceId, app, command, error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
// Keep this method private since it relies on pre-validated arguments
|
||||
function executeCommandAsApp(
|
||||
client: Client,
|
||||
deviceId: string,
|
||||
app: string,
|
||||
command: string,
|
||||
): Promise<string> {
|
||||
return _executeCommandWithRunner(
|
||||
client,
|
||||
deviceId,
|
||||
app,
|
||||
command,
|
||||
`run-as '${app}'`,
|
||||
);
|
||||
}
|
||||
|
||||
async function executeCommandWithSu(
|
||||
client: Client,
|
||||
deviceId: string,
|
||||
app: string,
|
||||
command: string,
|
||||
originalErrorToThrow: RunAsError,
|
||||
): Promise<string> {
|
||||
try {
|
||||
return _executeCommandWithRunner(client, deviceId, app, command, 'su');
|
||||
} catch (e) {
|
||||
console.debug(e);
|
||||
throw originalErrorToThrow;
|
||||
}
|
||||
}
|
||||
|
||||
function _executeCommandWithRunner(
|
||||
client: Client,
|
||||
deviceId: string,
|
||||
app: string,
|
||||
command: string,
|
||||
runner: string,
|
||||
): Promise<string> {
|
||||
return client
|
||||
.shell(deviceId, `echo '${command}' | ${runner}`)
|
||||
.then(adbkit.util.readAll)
|
||||
.then((buffer) => buffer.toString())
|
||||
.then((output) => {
|
||||
if (output.match(appNotApplicationRegex)) {
|
||||
throw new RunAsError(
|
||||
RunAsErrorCode.NotAnApp,
|
||||
`Android package ${app} is not an application. To use it with Flipper, either run adb as root or add an <application> tag to AndroidManifest.xml`,
|
||||
);
|
||||
}
|
||||
if (output.match(appNotDebuggableRegex)) {
|
||||
throw new RunAsError(
|
||||
RunAsErrorCode.NotDebuggable,
|
||||
`Android app ${app} is not debuggable. To use it with Flipper, add android:debuggable="true" to the application section of AndroidManifest.xml`,
|
||||
);
|
||||
}
|
||||
if (output.toLowerCase().match(operationNotPermittedRegex)) {
|
||||
throw new UnsupportedError(
|
||||
`Your android device (${deviceId}) does not support the adb shell run-as command. We're tracking this at https://github.com/facebook/flipper/issues/92`,
|
||||
);
|
||||
}
|
||||
return output;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 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 AndroidDevice from './AndroidDevice';
|
||||
import KaiOSDevice from './KaiOSDevice';
|
||||
import child_process from 'child_process';
|
||||
import {getAdbClient} from './adbClient';
|
||||
import which from 'which';
|
||||
import {promisify} from 'util';
|
||||
import {Client as ADBClient, Device} from 'adbkit';
|
||||
import {join} from 'path';
|
||||
import {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||
import {notNull} from '../../utils/typeUtils';
|
||||
import {
|
||||
getServerPortsConfig,
|
||||
getFlipperServerConfig,
|
||||
} from '../../FlipperServerConfig';
|
||||
|
||||
export class AndroidDeviceManager {
|
||||
// cache emulator path
|
||||
private emulatorPath: string | undefined;
|
||||
|
||||
constructor(public flipperServer: FlipperServerImpl) {}
|
||||
|
||||
private createDevice(
|
||||
adbClient: ADBClient,
|
||||
device: Device,
|
||||
): Promise<AndroidDevice | undefined> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const type =
|
||||
device.type !== 'device' || device.id.startsWith('emulator')
|
||||
? 'emulator'
|
||||
: 'physical';
|
||||
|
||||
try {
|
||||
const props = await adbClient.getProperties(device.id);
|
||||
try {
|
||||
let name = props['ro.product.model'];
|
||||
const abiString = props['ro.product.cpu.abilist'] || '';
|
||||
const sdkVersion = props['ro.build.version.sdk'] || '';
|
||||
const abiList = abiString.length > 0 ? abiString.split(',') : [];
|
||||
if (type === 'emulator') {
|
||||
name = (await this.getRunningEmulatorName(device.id)) || name;
|
||||
}
|
||||
const isKaiOSDevice = Object.keys(props).some(
|
||||
(name) => name.startsWith('kaios') || name.startsWith('ro.kaios'),
|
||||
);
|
||||
const androidLikeDevice = new (
|
||||
isKaiOSDevice ? KaiOSDevice : AndroidDevice
|
||||
)(
|
||||
this.flipperServer,
|
||||
device.id,
|
||||
type,
|
||||
name,
|
||||
adbClient,
|
||||
abiList,
|
||||
sdkVersion,
|
||||
);
|
||||
const ports = getServerPortsConfig();
|
||||
if (ports.serverPorts) {
|
||||
await androidLikeDevice
|
||||
.reverse([ports.serverPorts.secure, ports.serverPorts.insecure])
|
||||
// We may not be able to establish a reverse connection, e.g. for old Android SDKs.
|
||||
// This is *generally* fine, because we hard-code the ports on the SDK side.
|
||||
.catch((e) => {
|
||||
console.warn(
|
||||
`Failed to reverse-proxy ports on device ${androidLikeDevice.serial}: ${e}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
if (type === 'physical') {
|
||||
// forward port for React DevTools, which is fixed on React Native
|
||||
await androidLikeDevice.reverse([8097]).catch((e) => {
|
||||
console.warn(
|
||||
`Failed to reverse-proxy React DevTools port 8097 on ${androidLikeDevice.serial}`,
|
||||
e,
|
||||
);
|
||||
});
|
||||
}
|
||||
resolve(androidLikeDevice);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
} catch (e) {
|
||||
if (
|
||||
e &&
|
||||
e.message &&
|
||||
e.message === `Failure: 'device still connecting'`
|
||||
) {
|
||||
console.debug('Device still connecting: ' + device.id);
|
||||
} else {
|
||||
const isAuthorizationError = (e?.message as string)?.includes(
|
||||
'device unauthorized',
|
||||
);
|
||||
if (!isAuthorizationError) {
|
||||
console.error('Failed to connect to android device', e);
|
||||
}
|
||||
this.flipperServer.emit('notification', {
|
||||
type: 'error',
|
||||
title: 'Could not connect to ' + device.id,
|
||||
description: isAuthorizationError
|
||||
? 'Make sure to authorize debugging on the phone'
|
||||
: 'Failed to setup connection: ' + e,
|
||||
});
|
||||
}
|
||||
resolve(undefined); // not ready yet, we will find it in the next tick
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getEmulatorPath(): Promise<string> {
|
||||
if (this.emulatorPath) {
|
||||
return this.emulatorPath;
|
||||
}
|
||||
// TODO: this doesn't respect the currently configured android_home in settings!
|
||||
try {
|
||||
this.emulatorPath = (await promisify(which)('emulator')) as string;
|
||||
} catch (_e) {
|
||||
this.emulatorPath = join(
|
||||
process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || '',
|
||||
'tools',
|
||||
'emulator',
|
||||
);
|
||||
}
|
||||
return this.emulatorPath;
|
||||
}
|
||||
|
||||
async getAndroidEmulators(): Promise<string[]> {
|
||||
const emulatorPath = await this.getEmulatorPath();
|
||||
return new Promise<string[]>((resolve) => {
|
||||
child_process.execFile(
|
||||
emulatorPath as string,
|
||||
['-list-avds'],
|
||||
(error: Error | null, data: string | null) => {
|
||||
if (error != null || data == null) {
|
||||
console.warn('List AVD failed: ', error);
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
const devices = data
|
||||
.split('\n')
|
||||
.filter(notNull)
|
||||
.filter((l) => l !== '');
|
||||
resolve(devices);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async getRunningEmulatorName(
|
||||
id: string,
|
||||
): Promise<string | null | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const port = id.replace('emulator-', '');
|
||||
// The GNU version of netcat doesn't terminate after 1s when
|
||||
// specifying `-w 1`, so we kill it after a timeout. Because
|
||||
// of that, even in case of an error, there may still be
|
||||
// relevant data for us to parse.
|
||||
child_process.exec(
|
||||
`echo "avd name" | nc -w 1 localhost ${port}`,
|
||||
{timeout: 1000, encoding: 'utf-8'},
|
||||
(error: Error | null | undefined, data) => {
|
||||
if (data != null && typeof data === 'string') {
|
||||
const match = data.trim().match(/(.*)\r\nOK$/);
|
||||
resolve(match != null && match.length > 0 ? match[1] : null);
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async watchAndroidDevices() {
|
||||
try {
|
||||
const client = await getAdbClient(getFlipperServerConfig());
|
||||
client
|
||||
.trackDevices()
|
||||
.then((tracker) => {
|
||||
tracker.on('error', (err) => {
|
||||
if (err.message === 'Connection closed') {
|
||||
console.warn('adb server was shutdown');
|
||||
this.flipperServer
|
||||
.getDevices()
|
||||
.filter((d) => d instanceof AndroidDevice)
|
||||
.forEach((d) => {
|
||||
this.flipperServer.unregisterDevice(d.serial);
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.watchAndroidDevices();
|
||||
}, 500);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
tracker.on('add', async (device) => {
|
||||
if (device.type !== 'offline') {
|
||||
this.registerDevice(client, device);
|
||||
} else {
|
||||
console.warn(
|
||||
`[conn] Found device ${device.id}, but it has status offline. If this concerns an emulator and the problem persists, try these solutins: https://stackoverflow.com/a/21330228/1983583, https://stackoverflow.com/a/56053223/1983583`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
tracker.on('change', async (device) => {
|
||||
if (device.type === 'offline') {
|
||||
this.flipperServer.unregisterDevice(device.id);
|
||||
} else {
|
||||
this.registerDevice(client, device);
|
||||
}
|
||||
});
|
||||
|
||||
tracker.on('remove', (device) => {
|
||||
this.flipperServer.unregisterDevice(device.id);
|
||||
});
|
||||
})
|
||||
.catch((err: {code: string}) => {
|
||||
if (err.code === 'ECONNREFUSED') {
|
||||
console.warn('adb server not running');
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`Failed to watch for android devices: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async registerDevice(adbClient: ADBClient, deviceData: Device) {
|
||||
const androidDevice = await this.createDevice(adbClient, deviceData);
|
||||
if (!androidDevice) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.flipperServer.registerDevice(androidDevice);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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 {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||
import {ServerDevice} from '../ServerDevice';
|
||||
|
||||
export default class MacDevice extends ServerDevice {
|
||||
constructor(flipperServer: FlipperServerImpl) {
|
||||
super(flipperServer, {
|
||||
serial: '',
|
||||
deviceType: 'physical',
|
||||
title: 'Mac',
|
||||
os: 'MacOS',
|
||||
icon: 'app-apple',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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 {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||
import {ServerDevice} from '../ServerDevice';
|
||||
|
||||
export default class WindowsDevice extends ServerDevice {
|
||||
constructor(flipperServer: FlipperServerImpl) {
|
||||
super(flipperServer, {
|
||||
serial: '',
|
||||
deviceType: 'physical',
|
||||
title: 'Windows',
|
||||
os: 'Windows',
|
||||
icon: 'app-microsoft-windows',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 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 MacDevice from './MacDevice';
|
||||
import WindowsDevice from './WindowsDevice';
|
||||
import {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||
|
||||
export default (flipperServer: FlipperServerImpl) => {
|
||||
let device;
|
||||
if (process.platform === 'darwin') {
|
||||
device = new MacDevice(flipperServer);
|
||||
} else if (process.platform === 'win32') {
|
||||
device = new WindowsDevice(flipperServer);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
flipperServer.registerDevice(device);
|
||||
};
|
||||
184
desktop/flipper-server-core/src/devices/ios/IOSBridge.tsx
Normal file
184
desktop/flipper-server-core/src/devices/ios/IOSBridge.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 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 fs from 'fs-extra';
|
||||
import child_process from 'child_process';
|
||||
import {DeviceType} from 'flipper-plugin-lib';
|
||||
import {v1 as uuid} from 'uuid';
|
||||
import path from 'path';
|
||||
import {exec} from 'promisify-child-process';
|
||||
import {getFlipperServerConfig} from '../../FlipperServerConfig';
|
||||
|
||||
export const ERR_NO_IDB_OR_XCODE_AVAILABLE =
|
||||
'Neither Xcode nor idb available. Cannot provide iOS device functionality.';
|
||||
|
||||
export const ERR_PHYSICAL_DEVICE_LOGS_WITHOUT_IDB =
|
||||
'Cannot provide logs from a physical device without idb.';
|
||||
|
||||
export interface IOSBridge {
|
||||
startLogListener: (
|
||||
udid: string,
|
||||
deviceType: DeviceType,
|
||||
) => child_process.ChildProcessWithoutNullStreams;
|
||||
screenshot: (serial: string) => Promise<Buffer>;
|
||||
navigate: (serial: string, location: string) => Promise<void>;
|
||||
recordVideo: (
|
||||
serial: string,
|
||||
outputFile: string,
|
||||
) => child_process.ChildProcess;
|
||||
}
|
||||
|
||||
async function isAvailable(idbPath: string): Promise<boolean> {
|
||||
if (!idbPath) {
|
||||
return false;
|
||||
}
|
||||
return fs.promises
|
||||
.access(idbPath, fs.constants.X_OK)
|
||||
.then((_) => true)
|
||||
.catch((_) => false);
|
||||
}
|
||||
|
||||
function getLogExtraArgs(deviceType: DeviceType) {
|
||||
if (deviceType === 'physical') {
|
||||
return [
|
||||
// idb has a --json option, but that doesn't actually work for physical
|
||||
// devices!
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'--style',
|
||||
'json',
|
||||
'--predicate',
|
||||
'senderImagePath contains "Containers"',
|
||||
'--debug',
|
||||
'--info',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export function idbStartLogListener(
|
||||
idbPath: string,
|
||||
udid: string,
|
||||
deviceType: DeviceType,
|
||||
): child_process.ChildProcessWithoutNullStreams {
|
||||
return child_process.spawn(
|
||||
idbPath,
|
||||
['log', '--udid', udid, '--', ...getLogExtraArgs(deviceType)],
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
export function xcrunStartLogListener(udid: string, deviceType: DeviceType) {
|
||||
if (deviceType === 'physical') {
|
||||
throw new Error(ERR_PHYSICAL_DEVICE_LOGS_WITHOUT_IDB);
|
||||
}
|
||||
const deviceSetPath = process.env.DEVICE_SET_PATH
|
||||
? ['--set', process.env.DEVICE_SET_PATH]
|
||||
: [];
|
||||
return child_process.spawn(
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
...deviceSetPath,
|
||||
'spawn',
|
||||
udid,
|
||||
'log',
|
||||
'stream',
|
||||
...getLogExtraArgs(deviceType),
|
||||
],
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
function makeTempScreenshotFilePath() {
|
||||
const imageName = uuid() + '.png';
|
||||
return path.join(getFlipperServerConfig().tmpPath, imageName);
|
||||
}
|
||||
|
||||
async function runScreenshotCommand(
|
||||
command: string,
|
||||
imagePath: string,
|
||||
): Promise<Buffer> {
|
||||
await exec(command);
|
||||
const buffer = await fs.readFile(imagePath);
|
||||
await fs.unlink(imagePath);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export async function xcrunScreenshot(serial: string): Promise<Buffer> {
|
||||
const imagePath = makeTempScreenshotFilePath();
|
||||
const command = `xcrun simctl io ${serial} screenshot ${imagePath}`;
|
||||
return runScreenshotCommand(command, imagePath);
|
||||
}
|
||||
|
||||
export async function idbScreenshot(serial: string): Promise<Buffer> {
|
||||
const imagePath = makeTempScreenshotFilePath();
|
||||
const command = `idb screenshot --udid ${serial} ${imagePath}`;
|
||||
return runScreenshotCommand(command, imagePath);
|
||||
}
|
||||
|
||||
export async function xcrunNavigate(
|
||||
serial: string,
|
||||
location: string,
|
||||
): Promise<void> {
|
||||
exec(`xcrun simctl io ${serial} launch url "${location}"`);
|
||||
}
|
||||
|
||||
export async function idbNavigate(
|
||||
serial: string,
|
||||
location: string,
|
||||
): Promise<void> {
|
||||
exec(`idb open --udid ${serial} "${location}"`);
|
||||
}
|
||||
|
||||
export function xcrunRecordVideo(
|
||||
serial: string,
|
||||
outputFile: string,
|
||||
): child_process.ChildProcess {
|
||||
console.log(`Starting screen record via xcrun to ${outputFile}.`);
|
||||
return exec(
|
||||
`xcrun simctl io ${serial} recordVideo --codec=h264 --force ${outputFile}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function idbRecordVideo(
|
||||
serial: string,
|
||||
outputFile: string,
|
||||
): child_process.ChildProcess {
|
||||
console.log(`Starting screen record via idb to ${outputFile}.`);
|
||||
return exec(`idb record-video --udid ${serial} ${outputFile}`);
|
||||
}
|
||||
|
||||
export async function makeIOSBridge(
|
||||
idbPath: string,
|
||||
isXcodeDetected: boolean,
|
||||
isAvailableFn: (idbPath: string) => Promise<boolean> = isAvailable,
|
||||
): Promise<IOSBridge> {
|
||||
// prefer idb
|
||||
if (await isAvailableFn(idbPath)) {
|
||||
return {
|
||||
startLogListener: idbStartLogListener.bind(null, idbPath),
|
||||
screenshot: idbScreenshot,
|
||||
navigate: idbNavigate,
|
||||
recordVideo: idbRecordVideo,
|
||||
};
|
||||
}
|
||||
|
||||
// no idb, if it's a simulator and xcode is available, we can use xcrun
|
||||
if (isXcodeDetected) {
|
||||
return {
|
||||
startLogListener: xcrunStartLogListener,
|
||||
screenshot: xcrunScreenshot,
|
||||
navigate: xcrunNavigate,
|
||||
recordVideo: xcrunRecordVideo,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(ERR_NO_IDB_OR_XCODE_AVAILABLE);
|
||||
}
|
||||
296
desktop/flipper-server-core/src/devices/ios/IOSDevice.tsx
Normal file
296
desktop/flipper-server-core/src/devices/ios/IOSDevice.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 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 {
|
||||
DeviceLogLevel,
|
||||
DeviceLogEntry,
|
||||
DeviceType,
|
||||
timeout,
|
||||
} from 'flipper-common';
|
||||
import child_process, {ChildProcess} from 'child_process';
|
||||
import JSONStream from 'JSONStream';
|
||||
import {Transform} from 'stream';
|
||||
import {ERR_PHYSICAL_DEVICE_LOGS_WITHOUT_IDB, IOSBridge} from './IOSBridge';
|
||||
import split2 from 'split2';
|
||||
import {ServerDevice} from '../ServerDevice';
|
||||
import {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||
|
||||
type IOSLogLevel = 'Default' | 'Info' | 'Debug' | 'Error' | 'Fault';
|
||||
|
||||
type RawLogEntry = {
|
||||
eventMessage: string;
|
||||
machTimestamp: number;
|
||||
messageType: IOSLogLevel;
|
||||
processID: number;
|
||||
processImagePath: string;
|
||||
processImageUUID: string;
|
||||
processUniqueID: number;
|
||||
senderImagePath: string;
|
||||
senderImageUUID: string;
|
||||
senderProgramCounter: number;
|
||||
threadID: number;
|
||||
timestamp: string;
|
||||
timezoneName: string;
|
||||
traceID: string;
|
||||
};
|
||||
|
||||
// https://regex101.com/r/rrl03T/1
|
||||
// Mar 25 17:06:38 iPhone symptomsd(SymptomEvaluator)[125] <Notice>: Stuff
|
||||
const logRegex = /(^.{15}) ([^ ]+?) ([^\[]+?)\[(\d+?)\] <(\w+?)>: (.*)$/s;
|
||||
|
||||
export default class IOSDevice extends ServerDevice {
|
||||
log?: child_process.ChildProcessWithoutNullStreams;
|
||||
buffer: string;
|
||||
private recordingProcess?: ChildProcess;
|
||||
private recordingLocation?: string;
|
||||
private iOSBridge: IOSBridge;
|
||||
|
||||
constructor(
|
||||
flipperServer: FlipperServerImpl,
|
||||
iOSBridge: IOSBridge,
|
||||
serial: string,
|
||||
deviceType: DeviceType,
|
||||
title: string,
|
||||
) {
|
||||
super(flipperServer, {
|
||||
serial,
|
||||
deviceType,
|
||||
title,
|
||||
os: 'iOS',
|
||||
icon: 'mobile',
|
||||
});
|
||||
this.buffer = '';
|
||||
this.iOSBridge = iOSBridge;
|
||||
}
|
||||
|
||||
async screenshot(): Promise<Buffer> {
|
||||
if (!this.connected) {
|
||||
return Buffer.from([]);
|
||||
}
|
||||
return await this.iOSBridge.screenshot(this.serial);
|
||||
}
|
||||
|
||||
async navigateToLocation(location: string) {
|
||||
return this.iOSBridge.navigate(this.serial, location).catch((err) => {
|
||||
console.warn(`Failed to navigate to location ${location}:`, err);
|
||||
return err;
|
||||
});
|
||||
}
|
||||
|
||||
startLogging() {
|
||||
this.startLogListener(this.iOSBridge);
|
||||
}
|
||||
|
||||
stopLogging() {
|
||||
this.log?.kill();
|
||||
}
|
||||
|
||||
startLogListener(iOSBridge: IOSBridge, retries: number = 3) {
|
||||
if (retries === 0) {
|
||||
console.warn('Attaching iOS log listener continuously failed.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.log) {
|
||||
try {
|
||||
this.log = iOSBridge.startLogListener(
|
||||
this.serial,
|
||||
this.info.deviceType,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e.message === ERR_PHYSICAL_DEVICE_LOGS_WITHOUT_IDB) {
|
||||
console.warn(e);
|
||||
} else {
|
||||
console.error('Failed to initialise device logs:', e);
|
||||
this.startLogListener(iOSBridge, retries - 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.log.on('error', (err: Error) => {
|
||||
console.error('iOS log tailer error', err);
|
||||
});
|
||||
|
||||
this.log.stderr.on('data', (data: Buffer) => {
|
||||
console.warn('iOS log tailer stderr: ', data.toString());
|
||||
});
|
||||
|
||||
this.log.on('exit', () => {
|
||||
this.log = undefined;
|
||||
});
|
||||
|
||||
try {
|
||||
if (this.info.deviceType === 'physical') {
|
||||
this.log.stdout.pipe(split2('\0')).on('data', (line: string) => {
|
||||
const parsed = IOSDevice.parseLogLine(line);
|
||||
if (parsed) {
|
||||
this.addLogEntry(parsed);
|
||||
} else {
|
||||
console.warn('Failed to parse iOS log line: ', line);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.log.stdout
|
||||
.pipe(new StripLogPrefix())
|
||||
.pipe(JSONStream.parse('*'))
|
||||
.on('data', (data: RawLogEntry) => {
|
||||
const entry = IOSDevice.parseJsonLogEntry(data);
|
||||
this.addLogEntry(entry);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Could not parse iOS log stream.', e);
|
||||
// restart log stream
|
||||
this.log.kill();
|
||||
this.log = undefined;
|
||||
this.startLogListener(iOSBridge, retries - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static getLogLevel(level: string): DeviceLogLevel {
|
||||
switch (level) {
|
||||
case 'Default':
|
||||
return 'debug';
|
||||
case 'Info':
|
||||
return 'info';
|
||||
case 'Debug':
|
||||
return 'debug';
|
||||
case 'Error':
|
||||
return 'error';
|
||||
case 'Notice':
|
||||
return 'verbose';
|
||||
case 'Fault':
|
||||
return 'fatal';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
static parseLogLine(line: string): DeviceLogEntry | undefined {
|
||||
const matches = line.match(logRegex);
|
||||
if (matches) {
|
||||
return {
|
||||
date: new Date(Date.parse(matches[1])),
|
||||
tag: matches[3],
|
||||
tid: 0,
|
||||
pid: parseInt(matches[4], 10),
|
||||
type: IOSDevice.getLogLevel(matches[5]),
|
||||
message: matches[6],
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
static parseJsonLogEntry(entry: RawLogEntry): DeviceLogEntry {
|
||||
let type: DeviceLogLevel = IOSDevice.getLogLevel(entry.messageType);
|
||||
|
||||
// when Apple log levels are not used, log messages can be prefixed with
|
||||
// their loglevel.
|
||||
if (entry.eventMessage.startsWith('[debug]')) {
|
||||
type = 'debug';
|
||||
} else if (entry.eventMessage.startsWith('[info]')) {
|
||||
type = 'info';
|
||||
} else if (entry.eventMessage.startsWith('[warn]')) {
|
||||
type = 'warn';
|
||||
} else if (entry.eventMessage.startsWith('[error]')) {
|
||||
type = 'error';
|
||||
}
|
||||
// remove type from mesage
|
||||
entry.eventMessage = entry.eventMessage.replace(
|
||||
/^\[(debug|info|warn|error)\]/,
|
||||
'',
|
||||
);
|
||||
|
||||
const tag = entry.processImagePath.split('/').pop() || '';
|
||||
|
||||
return {
|
||||
date: new Date(entry.timestamp),
|
||||
pid: entry.processID,
|
||||
tid: entry.threadID,
|
||||
tag,
|
||||
message: entry.eventMessage,
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
async screenCaptureAvailable() {
|
||||
return this.info.deviceType === 'emulator' && this.connected;
|
||||
}
|
||||
|
||||
async startScreenCapture(destination: string) {
|
||||
this.recordingProcess = this.iOSBridge.recordVideo(
|
||||
this.serial,
|
||||
destination,
|
||||
);
|
||||
this.recordingLocation = destination;
|
||||
}
|
||||
|
||||
async stopScreenCapture(): Promise<string> {
|
||||
if (this.recordingProcess && this.recordingLocation) {
|
||||
const prom = new Promise<void>((resolve, _reject) => {
|
||||
this.recordingProcess!.on(
|
||||
'exit',
|
||||
async (_code: number | null, _signal: NodeJS.Signals | null) => {
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
this.recordingProcess!.kill('SIGINT');
|
||||
});
|
||||
|
||||
const output: string = await timeout<void>(
|
||||
5000,
|
||||
prom,
|
||||
'Timed out to stop a screen capture.',
|
||||
)
|
||||
.then(() => {
|
||||
const {recordingLocation} = this;
|
||||
this.recordingLocation = undefined;
|
||||
return recordingLocation!;
|
||||
})
|
||||
.catch((e) => {
|
||||
this.recordingLocation = undefined;
|
||||
console.warn('Failed to terminate iOS screen recording:', e);
|
||||
throw e;
|
||||
});
|
||||
return output;
|
||||
}
|
||||
throw new Error('No recording in progress');
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.recordingProcess && this.recordingLocation) {
|
||||
this.stopScreenCapture();
|
||||
}
|
||||
super.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// Used to strip the initial output of the logging utility where it prints out settings.
|
||||
// We know the log stream is json so it starts with an open brace.
|
||||
class StripLogPrefix extends Transform {
|
||||
passedPrefix = false;
|
||||
|
||||
_transform(
|
||||
data: any,
|
||||
_encoding: string,
|
||||
callback: (err?: Error, data?: any) => void,
|
||||
) {
|
||||
if (this.passedPrefix) {
|
||||
this.push(data);
|
||||
} else {
|
||||
const dataString = data.toString();
|
||||
const index = dataString.indexOf('[');
|
||||
if (index >= 0) {
|
||||
this.push(dataString.substring(index));
|
||||
this.passedPrefix = true;
|
||||
}
|
||||
}
|
||||
callback();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 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 {makeIOSBridge} from '../IOSBridge';
|
||||
import childProcess from 'child_process';
|
||||
import * as promisifyChildProcess from 'promisify-child-process';
|
||||
|
||||
jest.mock('child_process');
|
||||
jest.mock('promisify-child-process');
|
||||
|
||||
test('uses xcrun with no idb when xcode is detected', async () => {
|
||||
const ib = await makeIOSBridge('', true);
|
||||
|
||||
ib.startLogListener('deadbeef', 'emulator');
|
||||
|
||||
expect(childProcess.spawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
'deadbeef',
|
||||
'log',
|
||||
'stream',
|
||||
'--style',
|
||||
'json',
|
||||
'--predicate',
|
||||
'senderImagePath contains "Containers"',
|
||||
'--debug',
|
||||
'--info',
|
||||
],
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test('uses idb when present and xcode detected', async () => {
|
||||
const ib = await makeIOSBridge('/usr/local/bin/idb', true, async (_) => true);
|
||||
|
||||
ib.startLogListener('deadbeef', 'emulator');
|
||||
|
||||
expect(childProcess.spawn).toHaveBeenCalledWith(
|
||||
'/usr/local/bin/idb',
|
||||
[
|
||||
'log',
|
||||
'--udid',
|
||||
'deadbeef',
|
||||
'--',
|
||||
'--style',
|
||||
'json',
|
||||
'--predicate',
|
||||
'senderImagePath contains "Containers"',
|
||||
'--debug',
|
||||
'--info',
|
||||
],
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test('uses idb when present and xcode detected and physical device connected', async () => {
|
||||
const ib = await makeIOSBridge('/usr/local/bin/idb', true, async (_) => true);
|
||||
|
||||
ib.startLogListener('deadbeef', 'physical');
|
||||
|
||||
expect(childProcess.spawn).toHaveBeenCalledWith(
|
||||
'/usr/local/bin/idb',
|
||||
[
|
||||
'log',
|
||||
'--udid',
|
||||
'deadbeef',
|
||||
'--',
|
||||
// no further args; not supported by idb atm
|
||||
],
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test("without idb physical devices can't log", async () => {
|
||||
const ib = await makeIOSBridge('', true);
|
||||
expect(ib.startLogListener).toBeDefined(); // since we have xcode
|
||||
});
|
||||
|
||||
test('throws if no iOS support', async () => {
|
||||
await expect(makeIOSBridge('', false)).rejects.toThrow(
|
||||
'Neither Xcode nor idb available. Cannot provide iOS device functionality.',
|
||||
);
|
||||
});
|
||||
|
||||
test.unix(
|
||||
'uses xcrun to take screenshots with no idb when xcode is detected',
|
||||
async () => {
|
||||
const ib = await makeIOSBridge('', true);
|
||||
|
||||
ib.screenshot('deadbeef');
|
||||
|
||||
expect(promisifyChildProcess.exec).toHaveBeenCalledWith(
|
||||
'xcrun simctl io deadbeef screenshot /temp/00000000-0000-0000-0000-000000000000.png',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.unix('uses idb to take screenshots when available', async () => {
|
||||
const ib = await makeIOSBridge('/usr/local/bin/idb', true, async (_) => true);
|
||||
|
||||
ib.screenshot('deadbeef');
|
||||
|
||||
expect(promisifyChildProcess.exec).toHaveBeenCalledWith(
|
||||
'idb screenshot --udid deadbeef /temp/00000000-0000-0000-0000-000000000000.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('uses xcrun to navigate with no idb when xcode is detected', async () => {
|
||||
const ib = await makeIOSBridge('', true);
|
||||
|
||||
ib.navigate('deadbeef', 'fb://dummy');
|
||||
|
||||
expect(promisifyChildProcess.exec).toHaveBeenCalledWith(
|
||||
'xcrun simctl io deadbeef launch url "fb://dummy"',
|
||||
);
|
||||
});
|
||||
|
||||
test('uses idb to navigate when available', async () => {
|
||||
const ib = await makeIOSBridge('/usr/local/bin/idb', true, async (_) => true);
|
||||
|
||||
ib.navigate('deadbeef', 'fb://dummy');
|
||||
|
||||
expect(promisifyChildProcess.exec).toHaveBeenCalledWith(
|
||||
'idb open --udid deadbeef "fb://dummy"',
|
||||
);
|
||||
});
|
||||
|
||||
test('uses xcrun to record with no idb when xcode is detected', async () => {
|
||||
const ib = await makeIOSBridge('', true);
|
||||
|
||||
ib.recordVideo('deadbeef', '/tmp/video.mp4');
|
||||
|
||||
expect(promisifyChildProcess.exec).toHaveBeenCalledWith(
|
||||
'xcrun simctl io deadbeef recordVideo --codec=h264 --force /tmp/video.mp4',
|
||||
);
|
||||
});
|
||||
|
||||
test('uses idb to record when available', async () => {
|
||||
const ib = await makeIOSBridge('/usr/local/bin/idb', true, async (_) => true);
|
||||
|
||||
ib.recordVideo('deadbeef', '/tmo/video.mp4');
|
||||
|
||||
expect(promisifyChildProcess.exec).toHaveBeenCalledWith(
|
||||
'idb record-video --udid deadbeef /tmo/video.mp4',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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 {queryTargetsWithoutXcodeDependency} from '../iOSContainerUtility';
|
||||
|
||||
test('uses idbcompanion command for queryTargetsWithoutXcodeDependency', async () => {
|
||||
const mockedExec = jest.fn((_) =>
|
||||
Promise.resolve({
|
||||
stdout: '{"udid": "udid", "type": "physical", "name": "name"}',
|
||||
stderr: '{ "msg": "mocked stderr"}',
|
||||
}),
|
||||
);
|
||||
await queryTargetsWithoutXcodeDependency(
|
||||
'idbCompanionPath',
|
||||
true,
|
||||
(_) => Promise.resolve(true),
|
||||
mockedExec,
|
||||
);
|
||||
|
||||
expect(mockedExec).toBeCalledWith('idbCompanionPath --list 1 --only device');
|
||||
});
|
||||
|
||||
test('do not call idbcompanion if the path does not exist', async () => {
|
||||
const mockedExec = jest.fn((_) =>
|
||||
Promise.resolve({
|
||||
stdout: '{"udid": "udid", "type": "physical", "name": "name"}',
|
||||
stderr: '{"msg": "mocked stderr"}',
|
||||
}),
|
||||
);
|
||||
await queryTargetsWithoutXcodeDependency(
|
||||
'idbCompanionPath',
|
||||
true,
|
||||
(_) => Promise.resolve(false),
|
||||
mockedExec,
|
||||
);
|
||||
expect(mockedExec).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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 {parseXcodeFromCoreSimPath} from '../iOSDeviceManager';
|
||||
import {getLogger} from 'flipper-common';
|
||||
import {IOSBridge} from '../IOSBridge';
|
||||
import {FlipperServerImpl} from '../../../FlipperServerImpl';
|
||||
|
||||
const standardCoresimulatorLog =
|
||||
'username 1264 0.0 0.1 5989740 41648 ?? Ss 2:23PM 0:12.92 /Applications/Xcode_12.4.0_fb.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/libexec/mobileassetd';
|
||||
|
||||
const nonStandardCoresimulatorLog =
|
||||
'username 1264 0.0 0.1 5989740 41648 ?? Ss 2:23PM 0:12.92 /Some/Random/Path/Xcode_12.4.0_fb.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/libexec/mobileassetd';
|
||||
|
||||
const nonStandardSpecialCharacterAphanumericCoresimulatorLog =
|
||||
'username 1264 0.0 0.1 5989740 41648 ?? Ss 2:23PM 0:12.92 /Some_R@d0m/Path-3455355/path(2)+connection/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/libexec/mobileassetd';
|
||||
|
||||
test('test parseXcodeFromCoreSimPath from non standard locations', () => {
|
||||
const match = parseXcodeFromCoreSimPath(nonStandardCoresimulatorLog);
|
||||
expect(match && match.length > 0).toBeTruthy();
|
||||
expect(
|
||||
// @ts-ignore the null and non zero lenght check for match is already done above
|
||||
match[0],
|
||||
).toEqual('/Some/Random/Path/Xcode_12.4.0_fb.app/Contents/Developer');
|
||||
});
|
||||
|
||||
test('test parseXcodeFromCoreSimPath from non standard alphanumeric special character locations', () => {
|
||||
const match = parseXcodeFromCoreSimPath(
|
||||
nonStandardSpecialCharacterAphanumericCoresimulatorLog,
|
||||
);
|
||||
expect(match && match.length > 0).toBeTruthy();
|
||||
expect(
|
||||
// @ts-ignore the null and non zero lenght check for match is already done above
|
||||
match[0],
|
||||
).toEqual(
|
||||
'/Some_R@d0m/Path-3455355/path(2)+connection/Xcode.app/Contents/Developer',
|
||||
);
|
||||
});
|
||||
|
||||
test('test parseXcodeFromCoreSimPath from standard locations', () => {
|
||||
const match = parseXcodeFromCoreSimPath(standardCoresimulatorLog);
|
||||
expect(match && match.length > 0).toBeTruthy();
|
||||
expect(
|
||||
// @ts-ignore the null and non zero lenght check for match is already done above
|
||||
match[0],
|
||||
).toEqual('/Applications/Xcode_12.4.0_fb.app/Contents/Developer');
|
||||
});
|
||||
|
||||
test('test getAllPromisesForQueryingDevices when xcode detected', () => {
|
||||
const flipperServer = new FlipperServerImpl(getLogger());
|
||||
flipperServer.ios.iosBridge = {} as IOSBridge;
|
||||
const promises = flipperServer.ios.getAllPromisesForQueryingDevices(
|
||||
true,
|
||||
false,
|
||||
);
|
||||
expect(promises.length).toEqual(2);
|
||||
});
|
||||
|
||||
test('test getAllPromisesForQueryingDevices when xcode is not detected', () => {
|
||||
const flipperServer = new FlipperServerImpl(getLogger());
|
||||
flipperServer.ios.iosBridge = {} as IOSBridge;
|
||||
const promises = flipperServer.ios.getAllPromisesForQueryingDevices(
|
||||
false,
|
||||
true,
|
||||
);
|
||||
expect(promises.length).toEqual(1);
|
||||
});
|
||||
|
||||
test('test getAllPromisesForQueryingDevices when xcode and idb are both unavailable', () => {
|
||||
const flipperServer = new FlipperServerImpl(getLogger());
|
||||
flipperServer.ios.iosBridge = {} as IOSBridge;
|
||||
const promises = flipperServer.ios.getAllPromisesForQueryingDevices(
|
||||
false,
|
||||
false,
|
||||
);
|
||||
expect(promises.length).toEqual(0);
|
||||
});
|
||||
|
||||
test('test getAllPromisesForQueryingDevices when both idb and xcode are available', () => {
|
||||
const flipperServer = new FlipperServerImpl(getLogger());
|
||||
flipperServer.ios.iosBridge = {} as IOSBridge;
|
||||
const promises = flipperServer.ios.getAllPromisesForQueryingDevices(
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(promises.length).toEqual(2);
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 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 {Mutex} from 'async-mutex';
|
||||
import {exec as unsafeExec, Output} from 'promisify-child-process';
|
||||
import {reportPlatformFailures} from 'flipper-common';
|
||||
import {promises, constants} from 'fs';
|
||||
import memoize from 'lodash.memoize';
|
||||
import {notNull} from '../../utils/typeUtils';
|
||||
import {promisify} from 'util';
|
||||
import child_process from 'child_process';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
const exec = promisify(child_process.exec);
|
||||
|
||||
// Use debug to get helpful logs when idb fails
|
||||
const idbLogLevel = 'DEBUG';
|
||||
const operationPrefix = 'iosContainerUtility';
|
||||
|
||||
const mutex = new Mutex();
|
||||
|
||||
type IdbTarget = {
|
||||
name: string;
|
||||
udid: string;
|
||||
state: 'Booted' | 'Shutdown';
|
||||
type: string | DeviceType;
|
||||
target_type?: string | DeviceType;
|
||||
os_version: string;
|
||||
architecture: string;
|
||||
};
|
||||
|
||||
export type DeviceType = 'physical' | 'emulator';
|
||||
|
||||
export type DeviceTarget = {
|
||||
udid: string;
|
||||
type: DeviceType;
|
||||
name: string;
|
||||
};
|
||||
|
||||
function isAvailable(idbPath: string): Promise<boolean> {
|
||||
if (!idbPath) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return promises
|
||||
.access(idbPath, constants.X_OK)
|
||||
.then((_) => true)
|
||||
.catch((_) => false);
|
||||
}
|
||||
|
||||
function safeExec(
|
||||
command: string,
|
||||
): Promise<{stdout: string; stderr: string} | Output> {
|
||||
return mutex
|
||||
.acquire()
|
||||
.then((release) => unsafeExec(command).finally(release));
|
||||
}
|
||||
|
||||
export async function queryTargetsWithoutXcodeDependency(
|
||||
idbCompanionPath: string,
|
||||
isPhysicalDeviceEnabled: boolean,
|
||||
isAvailableFunc: (idbPath: string) => Promise<boolean>,
|
||||
safeExecFunc: (
|
||||
command: string,
|
||||
) => Promise<{stdout: string; stderr: string} | Output>,
|
||||
): Promise<Array<DeviceTarget>> {
|
||||
if (await isAvailableFunc(idbCompanionPath)) {
|
||||
return safeExecFunc(`${idbCompanionPath} --list 1 --only device`)
|
||||
.then(({stdout}) => parseIdbTargets(stdout!.toString()))
|
||||
.then((devices) => {
|
||||
if (devices.length > 0 && !isPhysicalDeviceEnabled) {
|
||||
// TODO: Show a notification to enable the toggle or integrate Doctor to better suggest this advice.
|
||||
console.warn(
|
||||
'You are trying to connect Physical Device. Please enable the toggle "Enable physical iOS device" from the setting screen.',
|
||||
);
|
||||
}
|
||||
return devices;
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
console.warn(
|
||||
'Failed to query idb_companion --list 1 --only device for physical targets:',
|
||||
e,
|
||||
);
|
||||
return [];
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
`Unable to locate idb_companion in ${idbCompanionPath}. Try running sudo yum install -y fb-idb`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseIdbTargets(lines: string): Array<DeviceTarget> {
|
||||
return lines
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
.filter(({state}: IdbTarget) => state.toLocaleLowerCase() === 'booted')
|
||||
.map<IdbTarget>(({type, target_type, ...rest}: IdbTarget) => ({
|
||||
type: (type || target_type) === 'simulator' ? 'emulator' : 'physical',
|
||||
...rest,
|
||||
}))
|
||||
.map<DeviceTarget>((target: IdbTarget) => ({
|
||||
udid: target.udid,
|
||||
type: target.type as DeviceType,
|
||||
name: target.name,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function idbListTargets(
|
||||
idbPath: string,
|
||||
safeExecFunc: (
|
||||
command: string,
|
||||
) => Promise<{stdout: string; stderr: string} | Output> = safeExec,
|
||||
): Promise<Array<DeviceTarget>> {
|
||||
return safeExecFunc(`${idbPath} list-targets --json`)
|
||||
.then(({stdout}) =>
|
||||
// See above.
|
||||
parseIdbTargets(stdout!.toString()),
|
||||
)
|
||||
.catch((e: Error) => {
|
||||
console.warn('Failed to query idb for targets:', e);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
async function targets(
|
||||
idbPath: string,
|
||||
isPhysicalDeviceEnabled: boolean,
|
||||
): Promise<Array<DeviceTarget>> {
|
||||
if (process.platform !== 'darwin') {
|
||||
return [];
|
||||
}
|
||||
const isXcodeInstalled = await isXcodeDetected();
|
||||
if (!isXcodeInstalled) {
|
||||
if (!isPhysicalDeviceEnabled) {
|
||||
// TODO: Show a notification to enable the toggle or integrate Doctor to better suggest this advice.
|
||||
console.warn(
|
||||
'You are trying to connect Physical Device. Please enable the toggle "Enable physical iOS device" from the setting screen.',
|
||||
);
|
||||
}
|
||||
const idbCompanionPath = path.dirname(idbPath) + '/idb_companion';
|
||||
return queryTargetsWithoutXcodeDependency(
|
||||
idbCompanionPath,
|
||||
isPhysicalDeviceEnabled,
|
||||
isAvailable,
|
||||
safeExec,
|
||||
);
|
||||
}
|
||||
|
||||
// Not all users have idb installed because you can still use
|
||||
// Flipper with Simulators without it.
|
||||
// But idb is MUCH more CPU efficient than xcrun, so
|
||||
// when installed, use it. This still holds true
|
||||
// with the move from instruments to xcrun.
|
||||
// TODO: Move idb availability check up.
|
||||
if (await memoize(isAvailable)(idbPath)) {
|
||||
return await idbListTargets(idbPath);
|
||||
} else {
|
||||
return safeExec('xcrun xctrace list devices')
|
||||
.then(({stdout}) =>
|
||||
stdout!
|
||||
.toString()
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => /(.+) \([^(]+\) \[(.*)\]( \(Simulator\))?/.exec(line))
|
||||
.filter(notNull)
|
||||
.filter(([_match, _name, _udid, isSim]) => !isSim)
|
||||
.map<DeviceTarget>(([_match, name, udid]) => {
|
||||
return {udid, type: 'physical', name};
|
||||
}),
|
||||
)
|
||||
.catch((e) => {
|
||||
console.warn('Failed to query for devices using xctrace:', e);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function push(
|
||||
udid: string,
|
||||
src: string,
|
||||
bundleId: string,
|
||||
dst: string,
|
||||
idbPath: string,
|
||||
): Promise<void> {
|
||||
await memoize(checkIdbIsInstalled)(idbPath);
|
||||
return wrapWithErrorMessage(
|
||||
reportPlatformFailures(
|
||||
safeExec(
|
||||
`${idbPath} --log ${idbLogLevel} file push --udid ${udid} --bundle-id ${bundleId} '${src}' '${dst}'`,
|
||||
)
|
||||
.then(() => {
|
||||
return;
|
||||
})
|
||||
.catch((e) => handleMissingIdb(e, idbPath)),
|
||||
`${operationPrefix}:push`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function pull(
|
||||
udid: string,
|
||||
src: string,
|
||||
bundleId: string,
|
||||
dst: string,
|
||||
idbPath: string,
|
||||
): Promise<void> {
|
||||
await memoize(checkIdbIsInstalled)(idbPath);
|
||||
return wrapWithErrorMessage(
|
||||
reportPlatformFailures(
|
||||
safeExec(
|
||||
`${idbPath} --log ${idbLogLevel} file pull --udid ${udid} --bundle-id ${bundleId} '${src}' '${dst}'`,
|
||||
)
|
||||
.then(() => {
|
||||
return;
|
||||
})
|
||||
.catch((e) => handleMissingIdb(e, idbPath))
|
||||
.catch((e) => handleMissingPermissions(e)),
|
||||
`${operationPrefix}:pull`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function checkIdbIsInstalled(idbPath: string): Promise<void> {
|
||||
const isInstalled = await isAvailable(idbPath);
|
||||
if (!isInstalled) {
|
||||
throw new Error(
|
||||
`idb is required to use iOS devices. Install it with instructions from https://github.com/facebook/idb and set the installation path in Flipper settings.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The fb-internal idb binary is a shim that downloads the proper one on first run. It requires sudo to do so.
|
||||
// If we detect this, Tell the user how to fix it.
|
||||
function handleMissingIdb(e: Error, idbPath: string): void {
|
||||
if (
|
||||
e.message &&
|
||||
e.message.includes('sudo: no tty present and no askpass program specified')
|
||||
) {
|
||||
console.warn(e);
|
||||
throw new Error(
|
||||
`idb doesn't appear to be installed. Run "${idbPath} list-targets" to fix this.`,
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
function handleMissingPermissions(e: Error): void {
|
||||
if (
|
||||
e.message &&
|
||||
e.message.includes('Command failed') &&
|
||||
e.message.includes('file pull') &&
|
||||
e.message.includes('sonar/app.csr')
|
||||
) {
|
||||
console.warn(e);
|
||||
throw new Error(
|
||||
'Cannot connect to iOS application. idb_certificate_pull_failed' +
|
||||
'Idb lacks permissions to exchange certificates. Did you install a source build ([FB] or enable certificate exchange)? ' +
|
||||
e,
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
function wrapWithErrorMessage<T>(p: Promise<T>): Promise<T> {
|
||||
return p.catch((e: Error) => {
|
||||
console.warn(e);
|
||||
// Give the user instructions. Don't embed the error because it's unique per invocation so won't be deduped.
|
||||
throw new Error(
|
||||
"A problem with idb has ocurred. Please run `sudo rm -rf /tmp/idb*` and `sudo yum install -y fb-idb` to update it, if that doesn't fix it, post in Flipper Support.",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function isXcodeDetected(): Promise<boolean> {
|
||||
return exec('xcode-select -p')
|
||||
.then(({stdout}) => {
|
||||
return fs.pathExists(stdout.trim());
|
||||
})
|
||||
.catch((_) => false);
|
||||
}
|
||||
|
||||
export default {
|
||||
isAvailable,
|
||||
targets,
|
||||
push,
|
||||
pull,
|
||||
isXcodeDetected,
|
||||
};
|
||||
307
desktop/flipper-server-core/src/devices/ios/iOSDeviceManager.tsx
Normal file
307
desktop/flipper-server-core/src/devices/ios/iOSDeviceManager.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* 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 {ChildProcess} from 'child_process';
|
||||
import type {IOSDeviceParams} from 'flipper-common';
|
||||
import path from 'path';
|
||||
import childProcess from 'child_process';
|
||||
import {exec, execFile} from 'promisify-child-process';
|
||||
import iosUtil from './iOSContainerUtility';
|
||||
import IOSDevice from './IOSDevice';
|
||||
import {
|
||||
ERR_NO_IDB_OR_XCODE_AVAILABLE,
|
||||
IOSBridge,
|
||||
makeIOSBridge,
|
||||
} from './IOSBridge';
|
||||
import {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||
import {notNull} from '../../utils/typeUtils';
|
||||
import {getFlipperServerConfig} from '../../FlipperServerConfig';
|
||||
|
||||
type iOSSimulatorDevice = {
|
||||
state: 'Booted' | 'Shutdown' | 'Shutting Down';
|
||||
availability?: string;
|
||||
isAvailable?: 'YES' | 'NO' | true | false;
|
||||
name: string;
|
||||
udid: string;
|
||||
};
|
||||
|
||||
function isAvailable(simulator: iOSSimulatorDevice): boolean {
|
||||
// For some users "availability" is set, for others it's "isAvailable"
|
||||
// It's not clear which key is set, so we are checking both.
|
||||
// We've also seen isAvailable return "YES" and true, depending on version.
|
||||
return (
|
||||
simulator.availability === '(available)' ||
|
||||
simulator.isAvailable === 'YES' ||
|
||||
simulator.isAvailable === true
|
||||
);
|
||||
}
|
||||
|
||||
export class IOSDeviceManager {
|
||||
private portForwarders: Array<ChildProcess> = [];
|
||||
|
||||
private portforwardingClient = path.join(
|
||||
getFlipperServerConfig().staticPath,
|
||||
'PortForwardingMacApp.app',
|
||||
'Contents',
|
||||
'MacOS',
|
||||
'PortForwardingMacApp',
|
||||
);
|
||||
iosBridge: IOSBridge | undefined;
|
||||
private xcodeVersionMismatchFound = false;
|
||||
public xcodeCommandLineToolsDetected = false;
|
||||
|
||||
constructor(private flipperServer: FlipperServerImpl) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.portForwarders.forEach((process) => process.kill());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private forwardPort(port: number, multiplexChannelPort: number) {
|
||||
const child = childProcess.execFile(
|
||||
this.portforwardingClient,
|
||||
[`-portForward=${port}`, `-multiplexChannelPort=${multiplexChannelPort}`],
|
||||
(err, stdout, stderr) => {
|
||||
// This happens on app reloads and doesn't need to be treated as an error.
|
||||
console.warn(
|
||||
'Port forwarding app failed to start',
|
||||
err,
|
||||
stdout,
|
||||
stderr,
|
||||
);
|
||||
},
|
||||
);
|
||||
console.log('Port forwarding app started', childProcess);
|
||||
child.addListener('error', (err) =>
|
||||
console.warn('Port forwarding app error', err),
|
||||
);
|
||||
child.addListener('exit', (code) =>
|
||||
console.log(`Port forwarding app exited with code ${code}`),
|
||||
);
|
||||
return child;
|
||||
}
|
||||
|
||||
private startDevicePortForwarders(): void {
|
||||
if (this.portForwarders.length > 0) {
|
||||
// Only ever start them once.
|
||||
return;
|
||||
}
|
||||
// start port forwarding server for real device connections
|
||||
// TODO: ports should be picked up from flipperServer.config?
|
||||
this.portForwarders = [
|
||||
this.forwardPort(8089, 8079),
|
||||
this.forwardPort(8088, 8078),
|
||||
];
|
||||
}
|
||||
|
||||
getAllPromisesForQueryingDevices(
|
||||
isXcodeDetected: boolean,
|
||||
isIdbAvailable: boolean,
|
||||
): Array<Promise<any>> {
|
||||
const config = getFlipperServerConfig();
|
||||
return [
|
||||
isIdbAvailable
|
||||
? getActiveDevices(config.idbPath, config.enablePhysicalIOS).then(
|
||||
(devices: IOSDeviceParams[]) => {
|
||||
this.processDevices(devices);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
!isIdbAvailable && isXcodeDetected
|
||||
? this.getSimulators(true).then((devices) =>
|
||||
this.processDevices(devices),
|
||||
)
|
||||
: null,
|
||||
isXcodeDetected ? this.checkXcodeVersionMismatch() : null,
|
||||
].filter(notNull);
|
||||
}
|
||||
|
||||
private async queryDevices(): Promise<any> {
|
||||
const config = getFlipperServerConfig();
|
||||
const isXcodeInstalled = await iosUtil.isXcodeDetected();
|
||||
const isIdbAvailable = await iosUtil.isAvailable(config.idbPath);
|
||||
return Promise.all(
|
||||
this.getAllPromisesForQueryingDevices(isXcodeInstalled, isIdbAvailable),
|
||||
);
|
||||
}
|
||||
|
||||
private processDevices(activeDevices: IOSDeviceParams[]) {
|
||||
if (!this.iosBridge) {
|
||||
throw new Error('iOS bridge not yet initialized');
|
||||
}
|
||||
const currentDeviceIDs = new Set(
|
||||
this.flipperServer
|
||||
.getDevices()
|
||||
.filter((device) => device.info.os === 'iOS')
|
||||
.map((device) => device.serial),
|
||||
);
|
||||
|
||||
for (const activeDevice of activeDevices) {
|
||||
const {udid, type, name} = activeDevice;
|
||||
if (currentDeviceIDs.has(udid)) {
|
||||
currentDeviceIDs.delete(udid);
|
||||
} else {
|
||||
console.info(`[conn] detected new iOS device ${udid}`, activeDevice);
|
||||
const iOSDevice = new IOSDevice(
|
||||
this.flipperServer,
|
||||
this.iosBridge,
|
||||
udid,
|
||||
type,
|
||||
name,
|
||||
);
|
||||
this.flipperServer.registerDevice(iOSDevice);
|
||||
}
|
||||
}
|
||||
|
||||
currentDeviceIDs.forEach((id) => {
|
||||
console.info(`[conn] Could no longer find ${id}, removing...`);
|
||||
this.flipperServer.unregisterDevice(id);
|
||||
});
|
||||
}
|
||||
|
||||
public async watchIOSDevices() {
|
||||
// TODO: pull this condition up
|
||||
if (!getFlipperServerConfig().enableIOS) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const isDetected = await iosUtil.isXcodeDetected();
|
||||
this.xcodeCommandLineToolsDetected = isDetected;
|
||||
if (getFlipperServerConfig().enablePhysicalIOS) {
|
||||
this.startDevicePortForwarders();
|
||||
}
|
||||
try {
|
||||
// Awaiting the promise here to trigger immediate error handling.
|
||||
this.iosBridge = await makeIOSBridge(
|
||||
getFlipperServerConfig().idbPath,
|
||||
isDetected,
|
||||
);
|
||||
this.queryDevicesForever();
|
||||
} catch (err) {
|
||||
// This case is expected if both Xcode and idb are missing.
|
||||
if (err.message === ERR_NO_IDB_OR_XCODE_AVAILABLE) {
|
||||
console.warn(
|
||||
'Failed to init iOS device. You may want to disable iOS support in the settings.',
|
||||
err,
|
||||
);
|
||||
} else {
|
||||
console.error('Failed to initialize iOS dispatcher:', err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error while querying iOS devices:', err);
|
||||
}
|
||||
}
|
||||
|
||||
getSimulators(bootedOnly: boolean): Promise<Array<IOSDeviceParams>> {
|
||||
return execFile('xcrun', [
|
||||
'simctl',
|
||||
...getDeviceSetPath(),
|
||||
'list',
|
||||
'devices',
|
||||
'--json',
|
||||
])
|
||||
.then(({stdout}) => JSON.parse(stdout!.toString()).devices)
|
||||
.then((simulatorDevices: Array<iOSSimulatorDevice>) => {
|
||||
const simulators = Object.values(simulatorDevices).flat();
|
||||
return simulators
|
||||
.filter(
|
||||
(simulator) =>
|
||||
(!bootedOnly || simulator.state === 'Booted') &&
|
||||
isAvailable(simulator),
|
||||
)
|
||||
.map((simulator) => {
|
||||
return {
|
||||
...simulator,
|
||||
type: 'emulator',
|
||||
} as IOSDeviceParams;
|
||||
});
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
console.warn('Failed to query simulators:', e);
|
||||
if (e.message.includes('Xcode license agreements')) {
|
||||
this.flipperServer.emit('notification', {
|
||||
type: 'error',
|
||||
title: 'Xcode license requires approval',
|
||||
description:
|
||||
'The Xcode license agreement has changed. You need to either open Xcode and agree to the terms or run `sudo xcodebuild -license` in a Terminal to allow simulators to work with Flipper.',
|
||||
});
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
}
|
||||
|
||||
private queryDevicesForever() {
|
||||
return this.queryDevices()
|
||||
.then(() => {
|
||||
// It's important to schedule the next check AFTER the current one has completed
|
||||
// to avoid simultaneous queries which can cause multiple user input prompts.
|
||||
setTimeout(() => this.queryDevicesForever(), 3000);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('Failed to continuously query devices:', err);
|
||||
});
|
||||
}
|
||||
|
||||
async checkXcodeVersionMismatch() {
|
||||
if (this.xcodeVersionMismatchFound) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let {stdout: xcodeCLIVersion} = await exec('xcode-select -p');
|
||||
xcodeCLIVersion = xcodeCLIVersion!.toString().trim();
|
||||
const {stdout} = await exec('ps aux | grep CoreSimulator');
|
||||
for (const line of stdout!.toString().split('\n')) {
|
||||
const match = parseXcodeFromCoreSimPath(line);
|
||||
const runningVersion =
|
||||
match && match.length > 0 ? match[0].trim() : null;
|
||||
if (runningVersion && runningVersion !== xcodeCLIVersion) {
|
||||
const errorMessage = `Xcode version mismatch: Simulator is running from "${runningVersion}" while Xcode CLI is "${xcodeCLIVersion}". Running "xcode-select --switch ${runningVersion}" can fix this. For example: "sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"`;
|
||||
this.flipperServer.emit('notification', {
|
||||
type: 'error',
|
||||
title: 'Xcode version mismatch',
|
||||
description: '' + errorMessage,
|
||||
});
|
||||
this.xcodeVersionMismatchFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to determine Xcode version:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDeviceSetPath() {
|
||||
return process.env.DEVICE_SET_PATH
|
||||
? ['--set', process.env.DEVICE_SET_PATH]
|
||||
: [];
|
||||
}
|
||||
|
||||
export async function launchSimulator(udid: string): Promise<any> {
|
||||
await execFile('xcrun', ['simctl', ...getDeviceSetPath(), 'boot', udid]);
|
||||
await execFile('open', ['-a', 'simulator']);
|
||||
}
|
||||
|
||||
function getActiveDevices(
|
||||
idbPath: string,
|
||||
isPhysicalDeviceEnabled: boolean,
|
||||
): Promise<Array<IOSDeviceParams>> {
|
||||
return iosUtil.targets(idbPath, isPhysicalDeviceEnabled).catch((e) => {
|
||||
console.error('Failed to get active iOS devices:', e.message);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export function parseXcodeFromCoreSimPath(
|
||||
line: string,
|
||||
): RegExpMatchArray | null {
|
||||
return line.match(/\/[\/\w@)(\-\+]*\/Xcode[^/]*\.app\/Contents\/Developer/);
|
||||
}
|
||||
119
desktop/flipper-server-core/src/devices/metro/MetroDevice.tsx
Normal file
119
desktop/flipper-server-core/src/devices/metro/MetroDevice.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 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 {DeviceLogLevel, MetroReportableEvent} from 'flipper-common';
|
||||
import util from 'util';
|
||||
import {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||
import {ServerDevice} from '../ServerDevice';
|
||||
|
||||
const metroLogLevelMapping: {[key: string]: DeviceLogLevel} = {
|
||||
trace: 'verbose',
|
||||
info: 'info',
|
||||
warn: 'warn',
|
||||
error: 'error',
|
||||
log: 'info',
|
||||
group: 'info',
|
||||
groupCollapsed: 'info',
|
||||
groupEnd: 'info',
|
||||
debug: 'debug',
|
||||
};
|
||||
|
||||
function getLoglevelFromMessageType(
|
||||
type: MetroReportableEvent['type'],
|
||||
): DeviceLogLevel | null {
|
||||
switch (type) {
|
||||
case 'bundle_build_done':
|
||||
case 'bundle_build_started':
|
||||
case 'initialize_done':
|
||||
return 'debug';
|
||||
case 'bundle_build_failed':
|
||||
case 'bundling_error':
|
||||
case 'global_cache_error':
|
||||
case 'hmr_client_error':
|
||||
return 'error';
|
||||
case 'bundle_transform_progressed':
|
||||
return null; // Don't show at all
|
||||
case 'client_log':
|
||||
return null; // Handled separately
|
||||
case 'dep_graph_loaded':
|
||||
case 'dep_graph_loading':
|
||||
case 'global_cache_disabled':
|
||||
default:
|
||||
return 'verbose';
|
||||
}
|
||||
}
|
||||
|
||||
export default class MetroDevice extends ServerDevice {
|
||||
ws?: WebSocket;
|
||||
|
||||
constructor(
|
||||
flipperServer: FlipperServerImpl,
|
||||
serial: string,
|
||||
ws: WebSocket | undefined,
|
||||
) {
|
||||
super(flipperServer, {
|
||||
serial,
|
||||
deviceType: 'emulator',
|
||||
title: 'React Native',
|
||||
os: 'Metro',
|
||||
icon: 'mobile',
|
||||
});
|
||||
if (ws) {
|
||||
this.ws = ws;
|
||||
ws.onmessage = this._handleWSMessage;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleWSMessage = ({data}: any) => {
|
||||
const message: MetroReportableEvent = JSON.parse(data);
|
||||
if (message.type === 'client_log') {
|
||||
const type: DeviceLogLevel =
|
||||
metroLogLevelMapping[message.level] || 'unknown';
|
||||
this.addLogEntry({
|
||||
date: new Date(),
|
||||
pid: 0,
|
||||
tid: 0,
|
||||
type,
|
||||
tag: message.type,
|
||||
message: util.format(
|
||||
...message.data.map((v) =>
|
||||
v && typeof v === 'object' ? JSON.stringify(v, null, 2) : v,
|
||||
),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
const level = getLoglevelFromMessageType(message.type);
|
||||
if (level !== null) {
|
||||
this.addLogEntry({
|
||||
date: new Date(),
|
||||
pid: 0,
|
||||
tid: 0,
|
||||
type: level,
|
||||
tag: message.type,
|
||||
message: JSON.stringify(message, null, 2),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sendCommand(command: string, params?: any) {
|
||||
if (this.ws) {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
type: 'command',
|
||||
command,
|
||||
params,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
console.warn('Cannot send command, no connection', command);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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 MetroDevice from './MetroDevice';
|
||||
import http from 'http';
|
||||
import {parseEnvironmentVariableAsNumber} from '../../utils/environmentVariables';
|
||||
import {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||
|
||||
const METRO_HOST = 'localhost';
|
||||
const METRO_PORT = parseEnvironmentVariableAsNumber('METRO_SERVER_PORT', 8081);
|
||||
const METRO_URL = `http://${METRO_HOST}:${METRO_PORT}`;
|
||||
const METRO_LOGS_ENDPOINT = `ws://${METRO_HOST}:${METRO_PORT}/events`;
|
||||
const METRO_MESSAGE = ['React Native packager is running', 'Metro is running'];
|
||||
const QUERY_INTERVAL = 5000;
|
||||
|
||||
async function isMetroRunning(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
// We use Node's http library, rather than fetch api, as the latter cannot supress network errors being shown in the devtools console
|
||||
// which generates a lot of noise
|
||||
http
|
||||
.get(METRO_URL, (resp) => {
|
||||
let data = '';
|
||||
resp
|
||||
.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
})
|
||||
.on('end', () => {
|
||||
const isMetro = METRO_MESSAGE.some((msg) => data.includes(msg));
|
||||
resolve(isMetro);
|
||||
});
|
||||
})
|
||||
.on('error', (err: any) => {
|
||||
if (err.code !== 'ECONNREFUSED' && err.code !== 'ECONNRESET') {
|
||||
console.error('Could not connect to METRO ' + err);
|
||||
}
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function registerMetroDevice(
|
||||
ws: WebSocket | undefined,
|
||||
flipperServer: FlipperServerImpl,
|
||||
) {
|
||||
const metroDevice = new MetroDevice(flipperServer, METRO_URL, ws);
|
||||
flipperServer.registerDevice(metroDevice);
|
||||
}
|
||||
|
||||
export default (flipperServer: FlipperServerImpl) => {
|
||||
let timeoutHandle: NodeJS.Timeout;
|
||||
let ws: WebSocket | undefined;
|
||||
let unregistered = false;
|
||||
|
||||
async function tryConnectToMetro() {
|
||||
if (ws) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await isMetroRunning()) {
|
||||
try {
|
||||
const _ws = new WebSocket(METRO_LOGS_ENDPOINT);
|
||||
|
||||
_ws.onopen = () => {
|
||||
clearTimeout(guard);
|
||||
ws = _ws;
|
||||
registerMetroDevice(ws, flipperServer);
|
||||
};
|
||||
|
||||
_ws.onclose = _ws.onerror = function (event?: any) {
|
||||
if (event?.type === 'error') {
|
||||
console.error(
|
||||
`Failed to connect to Metro on ${METRO_LOGS_ENDPOINT}`,
|
||||
event,
|
||||
);
|
||||
}
|
||||
if (!unregistered) {
|
||||
unregistered = true;
|
||||
clearTimeout(guard);
|
||||
ws = undefined;
|
||||
flipperServer.unregisterDevice(METRO_URL);
|
||||
scheduleNext();
|
||||
}
|
||||
};
|
||||
|
||||
const guard = setTimeout(() => {
|
||||
// Metro is running, but didn't respond to /events endpoint
|
||||
flipperServer.emit('notification', {
|
||||
type: 'error',
|
||||
title: 'Failed to connect to Metro',
|
||||
description: `Flipper did find a running Metro instance, but couldn't connect to the logs. Probably your React Native version is too old to support Flipper. Cause: Failed to get a connection to ${METRO_LOGS_ENDPOINT} in a timely fashion`,
|
||||
});
|
||||
registerMetroDevice(undefined, flipperServer);
|
||||
// Note: no scheduleNext, we won't retry until restart
|
||||
}, 5000);
|
||||
} catch (e) {
|
||||
console.error('Error while setting up Metro websocket connect', e);
|
||||
}
|
||||
} else {
|
||||
scheduleNext();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNext() {
|
||||
timeoutHandle = setTimeout(tryConnectToMetro, QUERY_INTERVAL);
|
||||
}
|
||||
|
||||
tryConnectToMetro();
|
||||
|
||||
// cleanup method
|
||||
return () => {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
if (timeoutHandle) {
|
||||
clearInterval(timeoutHandle);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -7,6 +7,10 @@
|
||||
* @format
|
||||
*/
|
||||
|
||||
export function helloWorld() {
|
||||
return true;
|
||||
}
|
||||
export {
|
||||
FlipperServerConfig,
|
||||
getFlipperServerConfig,
|
||||
setFlipperServerConfig,
|
||||
} from './FlipperServerConfig';
|
||||
|
||||
export {FlipperServerImpl} from './FlipperServerImpl';
|
||||
|
||||
684
desktop/flipper-server-core/src/utils/CertificateProvider.tsx
Normal file
684
desktop/flipper-server-core/src/utils/CertificateProvider.tsx
Normal file
@@ -0,0 +1,684 @@
|
||||
/**
|
||||
* 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 {Logger} from 'flipper-common';
|
||||
import {internGraphPOSTAPIRequest} from 'flipper-common';
|
||||
import ServerController from '../comms/ServerController';
|
||||
import {promisify} from 'util';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import {
|
||||
openssl,
|
||||
isInstalled as opensslInstalled,
|
||||
} from './openssl-wrapper-with-promises';
|
||||
import path from 'path';
|
||||
import tmp, {DirOptions, FileOptions} from 'tmp';
|
||||
import iosUtil from '../devices/ios/iOSContainerUtility';
|
||||
import {reportPlatformFailures} from 'flipper-common';
|
||||
import {getAdbClient} from '../devices/android/adbClient';
|
||||
import * as androidUtil from '../devices/android/androidContainerUtility';
|
||||
import os from 'os';
|
||||
import {Client as ADBClient} from 'adbkit';
|
||||
import archiver from 'archiver';
|
||||
import {timeout, isTest} from 'flipper-common';
|
||||
import {v4 as uuid} from 'uuid';
|
||||
|
||||
export type CertificateExchangeMedium = 'FS_ACCESS' | 'WWW' | 'NONE';
|
||||
|
||||
const tmpFile = promisify(tmp.file) as (
|
||||
options?: FileOptions,
|
||||
) => Promise<string>;
|
||||
const tmpDir = promisify(tmp.dir) as (options?: DirOptions) => Promise<string>;
|
||||
|
||||
// Desktop file paths
|
||||
const caKey = getFilePath('ca.key');
|
||||
const caCert = getFilePath('ca.crt');
|
||||
const serverKey = getFilePath('server.key');
|
||||
const serverCsr = getFilePath('server.csr');
|
||||
const serverSrl = getFilePath('server.srl');
|
||||
const serverCert = getFilePath('server.crt');
|
||||
|
||||
// Device file paths
|
||||
const csrFileName = 'app.csr';
|
||||
const deviceCAcertFile = 'sonarCA.crt';
|
||||
const deviceClientCertFile = 'device.crt';
|
||||
|
||||
const caSubject = '/C=US/ST=CA/L=Menlo Park/O=Sonar/CN=SonarCA';
|
||||
const serverSubject = '/C=US/ST=CA/L=Menlo Park/O=Sonar/CN=localhost';
|
||||
const minCertExpiryWindowSeconds = 24 * 60 * 60;
|
||||
const allowedAppNameRegex = /^[\w.-]+$/;
|
||||
const logTag = 'CertificateProvider';
|
||||
/*
|
||||
* RFC2253 specifies the unamiguous x509 subject format.
|
||||
* However, even when specifying this, different openssl implementations
|
||||
* wrap it differently, e.g "subject=X" vs "subject= X".
|
||||
*/
|
||||
const x509SubjectCNRegex = /[=,]\s*CN=([^,]*)(,.*)?$/;
|
||||
|
||||
export type SecureServerConfig = {
|
||||
key: Buffer;
|
||||
cert: Buffer;
|
||||
ca: Buffer;
|
||||
requestCert: boolean;
|
||||
rejectUnauthorized: boolean;
|
||||
};
|
||||
|
||||
type CertificateProviderConfig = {
|
||||
idbPath: string;
|
||||
enableAndroid: boolean;
|
||||
enableIOS: boolean;
|
||||
androidHome: string;
|
||||
enablePhysicalIOS: boolean;
|
||||
};
|
||||
|
||||
/*
|
||||
* This class is responsible for generating and deploying server and client
|
||||
* certificates to allow for secure communication between Flipper and apps.
|
||||
* It takes a Certificate Signing Request which was generated by the app,
|
||||
* using the app's public/private keypair.
|
||||
* With this CSR it uses the Flipper CA to sign a client certificate which it
|
||||
* deploys securely to the app.
|
||||
* It also deploys the Flipper CA cert to the app.
|
||||
* The app can trust a server if and only if it has a certificate signed by the
|
||||
* Flipper CA.
|
||||
*/
|
||||
export default class CertificateProvider {
|
||||
logger: Logger;
|
||||
_adb: Promise<ADBClient> | undefined;
|
||||
certificateSetup: Promise<void>;
|
||||
config: CertificateProviderConfig;
|
||||
server: ServerController;
|
||||
|
||||
get adb(): Promise<ADBClient> {
|
||||
if (this.config.enableAndroid) {
|
||||
if (this._adb) {
|
||||
return this._adb;
|
||||
}
|
||||
throw new Error(`ADB initialisation was not not successful`);
|
||||
}
|
||||
throw new Error('Android is not enabled in settings');
|
||||
}
|
||||
|
||||
constructor(
|
||||
server: ServerController,
|
||||
logger: Logger,
|
||||
config: CertificateProviderConfig,
|
||||
) {
|
||||
this.logger = logger;
|
||||
// TODO: refactor this code to create promise lazily
|
||||
this._adb = config.enableAndroid
|
||||
? (getAdbClient(config).catch((_e) => {
|
||||
// make sure initialization failure is already logged
|
||||
const msg =
|
||||
'Failed to initialize ADB. Please disable Android support in settings, or configure a correct path';
|
||||
server.flipperServer.emit('notification', {
|
||||
type: 'error',
|
||||
title: 'Failed to initialise ADB',
|
||||
description: msg,
|
||||
});
|
||||
this._adb = undefined; // no adb client available
|
||||
}) as Promise<ADBClient>)
|
||||
: undefined;
|
||||
if (isTest()) {
|
||||
this.certificateSetup = Promise.reject(
|
||||
new Error('Server certificates not available in test'),
|
||||
);
|
||||
} else {
|
||||
this.certificateSetup = reportPlatformFailures(
|
||||
this.ensureServerCertExists(),
|
||||
'ensureServerCertExists',
|
||||
);
|
||||
// make sure initialization failure is already logged
|
||||
this.certificateSetup.catch((e) => {
|
||||
console.error('Failed to find or generate certificates', e);
|
||||
});
|
||||
}
|
||||
this.config = config;
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
private uploadFiles = async (
|
||||
zipPath: string,
|
||||
deviceID: string,
|
||||
): Promise<void> => {
|
||||
const buff = await fs.readFile(zipPath);
|
||||
const file = new File([buff], 'certs.zip');
|
||||
return reportPlatformFailures(
|
||||
timeout(
|
||||
5 * 60 * 1000,
|
||||
internGraphPOSTAPIRequest('flipper/certificates', {
|
||||
certificate_zip: file,
|
||||
device_id: deviceID,
|
||||
}),
|
||||
'Timed out uploading Flipper certificates to WWW.',
|
||||
),
|
||||
'uploadCertificates',
|
||||
);
|
||||
};
|
||||
|
||||
async processCertificateSigningRequest(
|
||||
unsanitizedCsr: string,
|
||||
os: string,
|
||||
appDirectory: string,
|
||||
medium: CertificateExchangeMedium,
|
||||
): Promise<{deviceId: string}> {
|
||||
const csr = this.santitizeString(unsanitizedCsr);
|
||||
if (csr === '') {
|
||||
return Promise.reject(new Error(`Received empty CSR from ${os} device`));
|
||||
}
|
||||
this.ensureOpenSSLIsAvailable();
|
||||
const rootFolder = await promisify(tmp.dir)();
|
||||
const certFolder = rootFolder + '/FlipperCerts/';
|
||||
const certsZipPath = rootFolder + '/certs.zip';
|
||||
await this.certificateSetup;
|
||||
const caCert = await this.getCACertificate();
|
||||
await this.deployOrStageFileForMobileApp(
|
||||
appDirectory,
|
||||
deviceCAcertFile,
|
||||
caCert,
|
||||
csr,
|
||||
os,
|
||||
medium,
|
||||
certFolder,
|
||||
);
|
||||
const clientCert = await this.generateClientCertificate(csr);
|
||||
await this.deployOrStageFileForMobileApp(
|
||||
appDirectory,
|
||||
deviceClientCertFile,
|
||||
clientCert,
|
||||
csr,
|
||||
os,
|
||||
medium,
|
||||
certFolder,
|
||||
);
|
||||
const appName = await this.extractAppNameFromCSR(csr);
|
||||
const deviceId =
|
||||
medium === 'FS_ACCESS'
|
||||
? await this.getTargetDeviceId(os, appName, appDirectory, csr)
|
||||
: uuid();
|
||||
if (medium === 'WWW') {
|
||||
const zipPromise = new Promise((resolve, reject) => {
|
||||
const output = fs.createWriteStream(certsZipPath);
|
||||
const archive = archiver('zip', {
|
||||
zlib: {level: 9}, // Sets the compression level.
|
||||
});
|
||||
archive.directory(certFolder, false);
|
||||
output.on('close', function () {
|
||||
resolve(certsZipPath);
|
||||
});
|
||||
archive.on('warning', reject);
|
||||
archive.on('error', reject);
|
||||
archive.pipe(output);
|
||||
archive.finalize();
|
||||
});
|
||||
|
||||
await reportPlatformFailures(
|
||||
zipPromise,
|
||||
'www-certs-exchange-zipping-certs',
|
||||
);
|
||||
await reportPlatformFailures(
|
||||
this.uploadFiles(certsZipPath, deviceId),
|
||||
'www-certs-exchange-uploading-certs',
|
||||
);
|
||||
}
|
||||
return {
|
||||
deviceId,
|
||||
};
|
||||
}
|
||||
|
||||
getTargetDeviceId(
|
||||
os: string,
|
||||
appName: string,
|
||||
appDirectory: string,
|
||||
csr: string,
|
||||
): Promise<string> {
|
||||
if (os === 'Android') {
|
||||
return this.getTargetAndroidDeviceId(appName, appDirectory, csr);
|
||||
} else if (os === 'iOS') {
|
||||
return this.getTargetiOSDeviceId(appName, appDirectory, csr);
|
||||
} else if (os == 'MacOS') {
|
||||
return Promise.resolve('');
|
||||
}
|
||||
return Promise.resolve('unknown');
|
||||
}
|
||||
|
||||
private ensureOpenSSLIsAvailable(): void {
|
||||
if (!opensslInstalled()) {
|
||||
const e = Error(
|
||||
"It looks like you don't have OpenSSL installed. Please install it to continue.",
|
||||
);
|
||||
this.server.emit('error', e);
|
||||
}
|
||||
}
|
||||
|
||||
private getCACertificate(): Promise<string> {
|
||||
return fs.readFile(caCert, 'utf-8');
|
||||
}
|
||||
|
||||
private generateClientCertificate(csr: string): Promise<string> {
|
||||
console.debug('Creating new client cert', logTag);
|
||||
|
||||
return this.writeToTempFile(csr).then((path) => {
|
||||
return openssl('x509', {
|
||||
req: true,
|
||||
in: path,
|
||||
CA: caCert,
|
||||
CAkey: caKey,
|
||||
CAcreateserial: true,
|
||||
CAserial: serverSrl,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private getRelativePathInAppContainer(absolutePath: string) {
|
||||
const matches = /Application\/[^/]+\/(.*)/.exec(absolutePath);
|
||||
if (matches && matches.length === 2) {
|
||||
return matches[1];
|
||||
}
|
||||
throw new Error("Path didn't match expected pattern: " + absolutePath);
|
||||
}
|
||||
|
||||
private async deployOrStageFileForMobileApp(
|
||||
destination: string,
|
||||
filename: string,
|
||||
contents: string,
|
||||
csr: string,
|
||||
os: string,
|
||||
medium: CertificateExchangeMedium,
|
||||
certFolder: string,
|
||||
): Promise<void> {
|
||||
if (medium === 'WWW') {
|
||||
const certPathExists = await fs.pathExists(certFolder);
|
||||
if (!certPathExists) {
|
||||
await fs.mkdir(certFolder);
|
||||
}
|
||||
try {
|
||||
await fs.writeFile(certFolder + filename, contents);
|
||||
return;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to write ${filename} to temporary folder. Error: ${e}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const appName = await this.extractAppNameFromCSR(csr);
|
||||
|
||||
if (os === 'Android') {
|
||||
const deviceId = await this.getTargetAndroidDeviceId(
|
||||
appName,
|
||||
destination,
|
||||
csr,
|
||||
);
|
||||
const adbClient = await this.adb;
|
||||
await androidUtil.push(
|
||||
adbClient,
|
||||
deviceId,
|
||||
appName,
|
||||
destination + filename,
|
||||
contents,
|
||||
);
|
||||
} else if (
|
||||
os === 'iOS' ||
|
||||
os === 'windows' ||
|
||||
os == 'MacOS' /* Used by Spark AR?! */
|
||||
) {
|
||||
try {
|
||||
await fs.writeFile(destination + filename, contents);
|
||||
} catch (err) {
|
||||
// Writing directly to FS failed. It's probably a physical device.
|
||||
const relativePathInsideApp =
|
||||
this.getRelativePathInAppContainer(destination);
|
||||
const udid = await this.getTargetiOSDeviceId(appName, destination, csr);
|
||||
await this.pushFileToiOSDevice(
|
||||
udid,
|
||||
appName,
|
||||
relativePathInsideApp,
|
||||
filename,
|
||||
contents,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unsupported device OS for Certificate Exchange: ${os}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async pushFileToiOSDevice(
|
||||
udid: string,
|
||||
bundleId: string,
|
||||
destination: string,
|
||||
filename: string,
|
||||
contents: string,
|
||||
): Promise<void> {
|
||||
const dir = await tmpDir({unsafeCleanup: true});
|
||||
const filePath = path.resolve(dir, filename);
|
||||
await fs.writeFile(filePath, contents);
|
||||
await iosUtil.push(
|
||||
udid,
|
||||
filePath,
|
||||
bundleId,
|
||||
destination,
|
||||
this.config.idbPath,
|
||||
);
|
||||
}
|
||||
|
||||
private async getTargetAndroidDeviceId(
|
||||
appName: string,
|
||||
deviceCsrFilePath: string,
|
||||
csr: string,
|
||||
): Promise<string> {
|
||||
const devicesInAdb = await this.adb.then((client) => client.listDevices());
|
||||
if (devicesInAdb.length === 0) {
|
||||
throw new Error('No Android devices found');
|
||||
}
|
||||
const deviceMatchList = devicesInAdb.map(async (device) => {
|
||||
try {
|
||||
const result = await this.androidDeviceHasMatchingCSR(
|
||||
deviceCsrFilePath,
|
||||
device.id,
|
||||
appName,
|
||||
csr,
|
||||
);
|
||||
return {id: device.id, ...result, error: null};
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Unable to check for matching CSR in ${device.id}:${appName}`,
|
||||
logTag,
|
||||
e,
|
||||
);
|
||||
return {id: device.id, isMatch: false, foundCsr: null, error: e};
|
||||
}
|
||||
});
|
||||
const devices = await Promise.all(deviceMatchList);
|
||||
const matchingIds = devices.filter((m) => m.isMatch).map((m) => m.id);
|
||||
if (matchingIds.length == 0) {
|
||||
const erroredDevice = devices.find((d) => d.error);
|
||||
if (erroredDevice) {
|
||||
throw erroredDevice.error;
|
||||
}
|
||||
const foundCsrs = devices
|
||||
.filter((d) => d.foundCsr !== null)
|
||||
.map((d) => (d.foundCsr ? encodeURI(d.foundCsr) : 'null'));
|
||||
console.warn(`Looking for CSR (url encoded):
|
||||
|
||||
${encodeURI(this.santitizeString(csr))}
|
||||
|
||||
Found these:
|
||||
|
||||
${foundCsrs.join('\n\n')}`);
|
||||
throw new Error(`No matching device found for app: ${appName}`);
|
||||
}
|
||||
if (matchingIds.length > 1) {
|
||||
console.warn(
|
||||
new Error('[conn] More than one matching device found for CSR'),
|
||||
csr,
|
||||
);
|
||||
}
|
||||
return matchingIds[0];
|
||||
}
|
||||
|
||||
private async getTargetiOSDeviceId(
|
||||
appName: string,
|
||||
deviceCsrFilePath: string,
|
||||
csr: string,
|
||||
): Promise<string> {
|
||||
const matches = /\/Devices\/([^/]+)\//.exec(deviceCsrFilePath);
|
||||
if (matches && matches.length == 2) {
|
||||
// It's a simulator, the deviceId is in the filepath.
|
||||
return matches[1];
|
||||
}
|
||||
const targets = await iosUtil.targets(
|
||||
this.config.idbPath,
|
||||
this.config.enablePhysicalIOS,
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
throw new Error('No iOS devices found');
|
||||
}
|
||||
const deviceMatchList = targets.map(async (target) => {
|
||||
const isMatch = await this.iOSDeviceHasMatchingCSR(
|
||||
deviceCsrFilePath,
|
||||
target.udid,
|
||||
appName,
|
||||
csr,
|
||||
);
|
||||
return {id: target.udid, isMatch};
|
||||
});
|
||||
const devices = await Promise.all(deviceMatchList);
|
||||
const matchingIds = devices.filter((m) => m.isMatch).map((m) => m.id);
|
||||
if (matchingIds.length == 0) {
|
||||
throw new Error(`No matching device found for app: ${appName}`);
|
||||
}
|
||||
if (matchingIds.length > 1) {
|
||||
console.warn(`Multiple devices found for app: ${appName}`);
|
||||
}
|
||||
return matchingIds[0];
|
||||
}
|
||||
|
||||
private async androidDeviceHasMatchingCSR(
|
||||
directory: string,
|
||||
deviceId: string,
|
||||
processName: string,
|
||||
csr: string,
|
||||
): Promise<{isMatch: boolean; foundCsr: string}> {
|
||||
const adbClient = await this.adb;
|
||||
const deviceCsr = await androidUtil.pull(
|
||||
adbClient,
|
||||
deviceId,
|
||||
processName,
|
||||
directory + csrFileName,
|
||||
);
|
||||
// Santitize both of the string before comparation
|
||||
// The csr string extraction on client side return string in both way
|
||||
const [sanitizedDeviceCsr, sanitizedClientCsr] = [
|
||||
deviceCsr.toString(),
|
||||
csr,
|
||||
].map((s) => this.santitizeString(s));
|
||||
const isMatch = sanitizedDeviceCsr === sanitizedClientCsr;
|
||||
return {isMatch: isMatch, foundCsr: sanitizedDeviceCsr};
|
||||
}
|
||||
|
||||
private async iOSDeviceHasMatchingCSR(
|
||||
directory: string,
|
||||
deviceId: string,
|
||||
bundleId: string,
|
||||
csr: string,
|
||||
): Promise<boolean> {
|
||||
const originalFile = this.getRelativePathInAppContainer(
|
||||
path.resolve(directory, csrFileName),
|
||||
);
|
||||
const dir = await tmpDir({unsafeCleanup: true});
|
||||
await iosUtil.pull(
|
||||
deviceId,
|
||||
originalFile,
|
||||
bundleId,
|
||||
dir,
|
||||
this.config.idbPath,
|
||||
);
|
||||
const items = await fs.readdir(dir);
|
||||
if (items.length > 1) {
|
||||
throw new Error('Conflict in temp dir');
|
||||
}
|
||||
if (items.length === 0) {
|
||||
throw new Error('Failed to pull CSR from device');
|
||||
}
|
||||
const fileName = items[0];
|
||||
const copiedFile = path.resolve(dir, fileName);
|
||||
console.debug('Trying to read CSR from', copiedFile);
|
||||
const data = await fs.readFile(copiedFile);
|
||||
const csrFromDevice = this.santitizeString(data.toString());
|
||||
return csrFromDevice === this.santitizeString(csr);
|
||||
}
|
||||
|
||||
private santitizeString(csrString: string): string {
|
||||
return csrString.replace(/\r/g, '').trim();
|
||||
}
|
||||
|
||||
async extractAppNameFromCSR(csr: string): Promise<string> {
|
||||
const path = await this.writeToTempFile(csr);
|
||||
const subject = await openssl('req', {
|
||||
in: path,
|
||||
noout: true,
|
||||
subject: true,
|
||||
nameopt: true,
|
||||
RFC2253: false,
|
||||
});
|
||||
await fs.unlink(path);
|
||||
const matches = subject.trim().match(x509SubjectCNRegex);
|
||||
if (!matches || matches.length < 2) {
|
||||
throw new Error(`Cannot extract CN from ${subject}`);
|
||||
}
|
||||
const appName = matches[1];
|
||||
if (!appName.match(allowedAppNameRegex)) {
|
||||
throw new Error(
|
||||
`Disallowed app name in CSR: ${appName}. Only alphanumeric characters and '.' allowed.`,
|
||||
);
|
||||
}
|
||||
return appName;
|
||||
}
|
||||
|
||||
async loadSecureServerConfig(): Promise<SecureServerConfig> {
|
||||
await this.certificateSetup;
|
||||
return {
|
||||
key: await fs.readFile(serverKey),
|
||||
cert: await fs.readFile(serverCert),
|
||||
ca: await fs.readFile(caCert),
|
||||
requestCert: true,
|
||||
rejectUnauthorized: true, // can be false if necessary as we don't strictly need to verify the client
|
||||
};
|
||||
}
|
||||
|
||||
async ensureCertificateAuthorityExists(): Promise<void> {
|
||||
if (!(await fs.pathExists(caKey))) {
|
||||
return this.generateCertificateAuthority();
|
||||
}
|
||||
return this.checkCertIsValid(caCert).catch(() =>
|
||||
this.generateCertificateAuthority(),
|
||||
);
|
||||
}
|
||||
|
||||
private async checkCertIsValid(filename: string): Promise<void> {
|
||||
if (!(await fs.pathExists(filename))) {
|
||||
throw new Error(`${filename} does not exist`);
|
||||
}
|
||||
// openssl checkend is a nice feature but it only checks for certificates
|
||||
// expiring in the future, not those that have already expired.
|
||||
// So we need a separate check for certificates that have already expired
|
||||
// but since this involves parsing date outputs from openssl, which is less
|
||||
// reliable, keeping both checks for safety.
|
||||
try {
|
||||
await openssl('x509', {
|
||||
checkend: minCertExpiryWindowSeconds,
|
||||
in: filename,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Checking if certificate expire soon: ${filename}`,
|
||||
logTag,
|
||||
e,
|
||||
);
|
||||
const endDateOutput = await openssl('x509', {
|
||||
enddate: true,
|
||||
in: filename,
|
||||
noout: true,
|
||||
});
|
||||
const dateString = endDateOutput.trim().split('=')[1].trim();
|
||||
const expiryDate = Date.parse(dateString);
|
||||
if (isNaN(expiryDate)) {
|
||||
console.error(
|
||||
'Unable to parse certificate expiry date: ' + endDateOutput,
|
||||
);
|
||||
throw new Error(
|
||||
'Cannot parse certificate expiry date. Assuming it has expired.',
|
||||
);
|
||||
}
|
||||
if (expiryDate <= Date.now() + minCertExpiryWindowSeconds * 1000) {
|
||||
throw new Error('Certificate has expired or will expire soon.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyServerCertWasIssuedByCA() {
|
||||
const options: {
|
||||
[key: string]: any;
|
||||
} = {CAfile: caCert};
|
||||
options[serverCert] = false;
|
||||
const output = await openssl('verify', options);
|
||||
const verified = output.match(/[^:]+: OK/);
|
||||
if (!verified) {
|
||||
// This should never happen, but if it does, we need to notice so we can
|
||||
// generate a valid one, or no clients will trust our server.
|
||||
throw new Error('Current server cert was not issued by current CA');
|
||||
}
|
||||
}
|
||||
|
||||
private async generateCertificateAuthority(): Promise<void> {
|
||||
if (!(await fs.pathExists(getFilePath('')))) {
|
||||
await fs.mkdir(getFilePath(''));
|
||||
}
|
||||
console.log('Generating new CA', logTag);
|
||||
await openssl('genrsa', {out: caKey, '2048': false});
|
||||
await openssl('req', {
|
||||
new: true,
|
||||
x509: true,
|
||||
subj: caSubject,
|
||||
key: caKey,
|
||||
out: caCert,
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureServerCertExists(): Promise<void> {
|
||||
const allExist = await Promise.all([
|
||||
fs.pathExists(serverKey),
|
||||
fs.pathExists(serverCert),
|
||||
fs.pathExists(caCert),
|
||||
]).then((exist) => exist.every(Boolean));
|
||||
if (!allExist) {
|
||||
return this.generateServerCertificate();
|
||||
}
|
||||
|
||||
try {
|
||||
await this.checkCertIsValid(serverCert);
|
||||
await this.verifyServerCertWasIssuedByCA();
|
||||
} catch (e) {
|
||||
console.warn('Not all certs are valid, generating new ones', e);
|
||||
await this.generateServerCertificate();
|
||||
}
|
||||
}
|
||||
|
||||
private async generateServerCertificate(): Promise<void> {
|
||||
await this.ensureCertificateAuthorityExists();
|
||||
console.warn('Creating new server cert', logTag);
|
||||
await openssl('genrsa', {out: serverKey, '2048': false});
|
||||
await openssl('req', {
|
||||
new: true,
|
||||
key: serverKey,
|
||||
out: serverCsr,
|
||||
subj: serverSubject,
|
||||
});
|
||||
await openssl('x509', {
|
||||
req: true,
|
||||
in: serverCsr,
|
||||
CA: caCert,
|
||||
CAkey: caKey,
|
||||
CAcreateserial: true,
|
||||
CAserial: serverSrl,
|
||||
out: serverCert,
|
||||
});
|
||||
}
|
||||
|
||||
private async writeToTempFile(content: string): Promise<string> {
|
||||
const path = await tmpFile();
|
||||
await fs.writeFile(path, content);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
function getFilePath(fileName: string): string {
|
||||
return path.resolve(os.homedir(), '.flipper', 'certs', fileName);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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 {parseFlipperPorts} from '../environmentVariables';
|
||||
|
||||
test('Valid port overrides are parsed correctly', () => {
|
||||
const overrides = parseFlipperPorts('1111,2222');
|
||||
expect(overrides).toEqual({insecure: 1111, secure: 2222});
|
||||
});
|
||||
|
||||
test('Malformed numbers are ignored', () => {
|
||||
const malformed1 = parseFlipperPorts('1111,22s22');
|
||||
expect(malformed1).toBe(undefined);
|
||||
|
||||
const malformed2 = parseFlipperPorts('11a11,2222');
|
||||
expect(malformed2).toBe(undefined);
|
||||
});
|
||||
|
||||
test('Wrong number of values is ignored', () => {
|
||||
const overrides = parseFlipperPorts('1111');
|
||||
expect(overrides).toBe(undefined);
|
||||
});
|
||||
|
||||
test('Empty values are ignored', () => {
|
||||
const overrides = parseFlipperPorts('1111,');
|
||||
expect(overrides).toBe(undefined);
|
||||
});
|
||||
|
||||
test('Negative values are ignored', () => {
|
||||
const overrides = parseFlipperPorts('-1111,2222');
|
||||
expect(overrides).toBe(undefined);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
export function parseFlipperPorts(
|
||||
envVar: string,
|
||||
): {insecure: number; secure: number} | undefined {
|
||||
const components = envVar.split(',');
|
||||
const ports = components.map((x) => parseInt(x, 10));
|
||||
|
||||
// Malformed numbers will get parsed to NaN which is not > 0
|
||||
if (
|
||||
ports.length === 2 &&
|
||||
components.every((x) => /^\d+$/.test(x)) &&
|
||||
ports.every((x) => x > 0)
|
||||
) {
|
||||
return {
|
||||
insecure: ports[0],
|
||||
secure: ports[1],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function parseEnvironmentVariableAsNumber(
|
||||
envVarName: string,
|
||||
defaultValue?: number,
|
||||
): number | undefined {
|
||||
const envVarAsString = process.env[envVarName];
|
||||
if (envVarAsString) {
|
||||
const parsedInt = parseInt(envVarAsString, 10);
|
||||
return isNaN(parsedInt) ? defaultValue : parsedInt;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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 {exec as opensslWithCallback, Action} from 'openssl-wrapper';
|
||||
import child_process from 'child_process';
|
||||
|
||||
export function openssl(action: Action, options: {}): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
opensslWithCallback(action, options, (err, buffer) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (buffer) {
|
||||
resolve(buffer.toString());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function isInstalled(): boolean {
|
||||
return !child_process.spawnSync('openssl', ['version']).error;
|
||||
}
|
||||
14
desktop/flipper-server-core/src/utils/typeUtils.tsx
Normal file
14
desktop/flipper-server-core/src/utils/typeUtils.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
// Typescript doesn't know Array.filter(Boolean) won't contain nulls.
|
||||
// So use Array.filter(notNull) instead.
|
||||
export function notNull<T>(x: T | null | undefined): x is T {
|
||||
return x !== null && x !== undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user