Files
flipper/desktop/app/src/reducers/connections.tsx
Michel Weststrate 0cbe063c77 Move UnitializedClient to server
Summary: Move UnitializedClient interface to server namespace

Reviewed By: timur-valiev

Differential Revision: D30367328

fbshipit-source-id: 367a607fa2bb20dd140b1ad28946073ca764e517
2021-08-17 07:51:51 -07:00

709 lines
18 KiB
TypeScript

/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {ComponentType} from 'react';
import {produce} from 'immer';
import type BaseDevice from '../server/devices/BaseDevice';
import MacDevice from '../server/devices/desktop/MacDevice';
import type Client from '../Client';
import type {UninitializedClient} from '../server/UninitializedClient';
import {isEqual} from 'lodash';
import {performance} from 'perf_hooks';
import type {Actions, Store} from '.';
import {WelcomeScreenStaticView} from '../sandy-chrome/WelcomeScreen';
import {isDevicePluginDefinition} from '../utils/pluginUtils';
import {getPluginKey} from '../utils/pluginKey';
import {deconstructClientId} from '../utils/clientUtils';
import type {RegisterPluginAction} from './plugins';
import MetroDevice from '../server/devices/metro/MetroDevice';
import {Logger} from 'flipper-plugin';
import {FlipperServer} from '../server/FlipperServer';
export type StaticViewProps = {logger: Logger};
export type StaticView =
| null
| ComponentType<StaticViewProps>
| React.FunctionComponent<any>;
export type State = StateV2;
export const persistVersion = 2;
export const persistMigrations = {
1: (state: any) => {
const stateV0 = state as StateV0;
const stateV1 = {
...stateV0,
enabledPlugins: stateV0.userStarredPlugins ?? {},
enabledDevicePlugins:
stateV0.userStarredDevicePlugins ??
new Set<string>(INITAL_STATE.enabledDevicePlugins),
};
return stateV1 as any;
},
2: (state: any) => {
const stateV1 = state as StateV1;
const stateV2 = {
...stateV1,
enabledPlugins: stateV1.enabledPlugins ?? {},
enabledDevicePlugins:
stateV1.enabledDevicePlugins ??
new Set<string>(INITAL_STATE.enabledDevicePlugins),
};
return stateV2 as any;
},
};
type StateV2 = {
devices: Array<BaseDevice>;
selectedDevice: null | BaseDevice;
selectedPlugin: null | string;
selectedApp: null | string;
userPreferredDevice: null | string;
userPreferredPlugin: null | string;
userPreferredApp: null | string;
enabledPlugins: {[client: string]: string[]};
enabledDevicePlugins: Set<string>;
clients: Array<Client>;
uninitializedClients: Array<{
client: UninitializedClient;
deviceId?: string;
errorMessage?: string;
}>;
deepLinkPayload: unknown;
staticView: StaticView;
selectedAppPluginListRevision: number;
flipperServer: FlipperServer | undefined;
};
type StateV1 = Omit<StateV2, 'enabledPlugins' | 'enabledDevicePlugins'> & {
enabledPlugins?: {[client: string]: string[]};
enabledDevicePlugins?: Set<string>;
};
type StateV0 = Omit<StateV1, 'enabledPlugins' | 'enabledDevicePlugins'> & {
userStarredPlugins?: {[client: string]: string[]};
userStarredDevicePlugins?: Set<string>;
};
export type Action =
| {
type: 'UNREGISTER_DEVICES';
payload: Set<string>;
}
| {
type: 'REGISTER_DEVICE';
payload: BaseDevice;
}
| {
type: 'SELECT_DEVICE';
payload: BaseDevice;
}
| {
type: 'SELECT_PLUGIN';
payload: {
selectedPlugin: null | string;
selectedApp?: null | string;
deepLinkPayload: unknown;
selectedDevice?: null | BaseDevice;
time: number;
};
}
| {
type: 'SELECT_USER_PREFERRED_PLUGIN';
payload: string;
}
| {
type: 'NEW_CLIENT';
payload: Client;
}
| {
type: 'CLIENT_REMOVED';
payload: string;
}
| {
type: 'PREFER_DEVICE';
payload: string;
}
| {
type: 'START_CLIENT_SETUP';
payload: UninitializedClient;
}
| {
type: 'FINISH_CLIENT_SETUP';
payload: {client: UninitializedClient; deviceId: string};
}
| {
type: 'SET_STATIC_VIEW';
payload: StaticView;
deepLinkPayload: unknown;
}
| {
type: 'SET_PLUGIN_ENABLED';
payload: {
pluginId: string;
selectedApp: string;
};
}
| {
type: 'SET_DEVICE_PLUGIN_ENABLED';
payload: {
pluginId: string;
};
}
| {
type: 'SET_PLUGIN_DISABLED';
payload: {
pluginId: string;
selectedApp: string;
};
}
| {
type: 'SET_DEVICE_PLUGIN_DISABLED';
payload: {
pluginId: string;
};
}
| {
type: 'SELECT_CLIENT';
payload: string | null;
}
| {
type: 'APP_PLUGIN_LIST_CHANGED';
}
| {
type: 'SET_FLIPPER_SERVER';
payload: FlipperServer;
}
| RegisterPluginAction;
const DEFAULT_PLUGIN = 'DeviceLogs';
const DEFAULT_DEVICE_BLACKLIST = [MacDevice, MetroDevice];
const INITAL_STATE: State = {
devices: [],
selectedDevice: null,
selectedApp: null,
selectedPlugin: DEFAULT_PLUGIN,
userPreferredDevice: null,
userPreferredPlugin: null,
userPreferredApp: null,
enabledPlugins: {},
enabledDevicePlugins: new Set([
'DeviceLogs',
'CrashReporter',
'MobileBuilds',
'Hermesdebuggerrn',
'React',
]),
clients: [],
uninitializedClients: [],
deepLinkPayload: null,
staticView: WelcomeScreenStaticView,
selectedAppPluginListRevision: 0,
flipperServer: undefined,
};
export default (state: State = INITAL_STATE, action: Actions): State => {
switch (action.type) {
case 'SET_FLIPPER_SERVER': {
return {
...state,
flipperServer: action.payload,
};
}
case 'SET_STATIC_VIEW': {
const {payload, deepLinkPayload} = action;
const {selectedPlugin} = state;
return {
...state,
staticView: payload,
selectedPlugin: payload != null ? null : selectedPlugin,
deepLinkPayload: deepLinkPayload ?? null,
};
}
case 'RESET_SUPPORT_FORM_V2_STATE': {
return updateSelection({
...state,
staticView: null,
});
}
case 'SELECT_DEVICE': {
const {payload} = action;
return updateSelection({
...state,
staticView: null,
selectedDevice: payload,
userPreferredDevice: payload
? payload.title
: state.userPreferredDevice,
});
}
case 'REGISTER_DEVICE': {
const {payload} = action;
const newDevices = state.devices.slice();
const existing = state.devices.findIndex(
(device) => device.serial === payload.serial,
);
if (existing !== -1) {
newDevices[existing].destroy();
newDevices[existing] = payload;
} else {
newDevices.push(payload);
}
return updateSelection({
...state,
devices: newDevices,
});
}
// TODO: remove
case 'UNREGISTER_DEVICES': {
const deviceSerials = action.payload;
return updateSelection(
produce(state, (draft) => {
draft.devices = draft.devices.filter((device) => {
if (!deviceSerials.has(device.serial)) {
return true;
} else {
if (device.connected.get()) {
console.warn(
'Tried to unregister a device before it was destroyed',
);
}
return false;
}
});
}),
);
}
case 'SELECT_PLUGIN': {
const {payload} = action;
const {selectedPlugin, selectedApp, deepLinkPayload} = payload;
let selectedDevice = payload.selectedDevice;
if (typeof deepLinkPayload === 'string') {
const deepLinkParams = new URLSearchParams(deepLinkPayload);
const deviceParam = deepLinkParams.get('device');
if (deviceParam) {
const deviceMatch = state.devices.find(
(v) => v.title === deviceParam,
);
if (deviceMatch) {
selectedDevice = deviceMatch;
} else {
console.warn(
`Could not find matching device "${deviceParam}" requested through deep-link.`,
);
}
}
}
if (!selectedDevice && selectedPlugin) {
const selectedClient = state.clients.find((c) =>
c.supportsPlugin(selectedPlugin),
);
selectedDevice = state.devices.find(
(v) => v.serial === selectedClient?.query.device_id,
);
}
if (!selectedDevice) {
console.warn('Trying to select a plugin before a device was selected!');
}
if (selectedPlugin) {
performance.mark(`activePlugin-${selectedPlugin}`);
}
return updateSelection({
...state,
staticView: null,
selectedApp: selectedApp || null,
selectedPlugin,
userPreferredPlugin: selectedPlugin || state.userPreferredPlugin,
selectedDevice: selectedDevice!,
userPreferredDevice:
selectedDevice && canBeDefaultDevice(selectedDevice)
? selectedDevice.title
: state.userPreferredDevice,
deepLinkPayload: deepLinkPayload,
});
}
case 'SELECT_USER_PREFERRED_PLUGIN': {
const {payload} = action;
return {...state, userPreferredPlugin: payload};
}
case 'NEW_CLIENT': {
const {payload} = action;
const newClients = state.clients.filter((client) => {
if (client.id === payload.id) {
console.error(
`Received a new connection for client ${client.id}, but the old connection was not cleaned up`,
);
return false;
}
return true;
});
newClients.push(payload);
return updateSelection({
...state,
clients: newClients,
uninitializedClients: state.uninitializedClients.filter((c) => {
return (
c.deviceId !== payload.query.device_id ||
c.client.appName !== payload.query.app
);
}),
});
}
case 'SELECT_CLIENT': {
const {payload} = action;
return updateSelection({
...state,
selectedApp: payload,
userPreferredApp: payload || state.userPreferredApp,
});
}
case 'CLIENT_REMOVED': {
const {payload} = action;
const newClients = state.clients.filter(
(client) => client.id !== payload,
);
return updateSelection({
...state,
clients: newClients,
});
}
case 'PREFER_DEVICE': {
const {payload: userPreferredDevice} = action;
return {...state, userPreferredDevice};
}
case 'START_CLIENT_SETUP': {
const {payload} = action;
return {
...state,
uninitializedClients: state.uninitializedClients
.filter((entry) => !isEqual(entry.client, payload))
.concat([{client: payload}])
.sort((a, b) => a.client.appName.localeCompare(b.client.appName)),
};
}
case 'FINISH_CLIENT_SETUP': {
const {payload} = action;
return {
...state,
uninitializedClients: state.uninitializedClients
.map((c) =>
isEqual(c.client, payload.client)
? {...c, deviceId: payload.deviceId}
: c,
)
.sort((a, b) => a.client.appName.localeCompare(b.client.appName)),
};
}
case 'REGISTER_PLUGINS': {
// plugins are registered after creating the base devices, so update them
const plugins = action.payload;
plugins.forEach((plugin) => {
if (isDevicePluginDefinition(plugin)) {
// smell: devices are mutable
state.devices.forEach((device) => {
device.loadDevicePlugin(plugin);
});
}
});
return state;
}
case 'SET_PLUGIN_ENABLED': {
const {pluginId, selectedApp} = action.payload;
return produce(state, (draft) => {
if (!draft.enabledPlugins[selectedApp]) {
draft.enabledPlugins[selectedApp] = [];
}
const plugins = draft.enabledPlugins[selectedApp];
const idx = plugins.indexOf(pluginId);
if (idx === -1) {
plugins.push(pluginId);
}
});
}
case 'SET_DEVICE_PLUGIN_ENABLED': {
const {pluginId} = action.payload;
return produce(state, (draft) => {
draft.enabledDevicePlugins.add(pluginId);
});
}
case 'SET_PLUGIN_DISABLED': {
const {pluginId, selectedApp} = action.payload;
return produce(state, (draft) => {
if (!draft.enabledPlugins[selectedApp]) {
draft.enabledPlugins[selectedApp] = [];
}
const plugins = draft.enabledPlugins[selectedApp];
const idx = plugins.indexOf(pluginId);
if (idx !== -1) {
plugins.splice(idx, 1);
}
});
}
case 'SET_DEVICE_PLUGIN_DISABLED': {
const {pluginId} = action.payload;
return produce(state, (draft) => {
draft.enabledDevicePlugins.delete(pluginId);
});
}
case 'APP_PLUGIN_LIST_CHANGED': {
return produce(state, (draft) => {
draft.selectedAppPluginListRevision++;
});
}
default:
return state;
}
};
export const selectDevice = (payload: BaseDevice): Action => ({
type: 'SELECT_DEVICE',
payload,
});
export const setStaticView = (
payload: StaticView,
deepLinkPayload?: unknown,
): Action => {
if (!payload) {
throw new Error('Cannot set empty static view');
}
return {
type: 'SET_STATIC_VIEW',
payload,
deepLinkPayload,
};
};
export const preferDevice = (payload: string): Action => ({
type: 'PREFER_DEVICE',
payload,
});
export const selectPlugin = (payload: {
selectedPlugin: null | string;
selectedApp?: null | string;
selectedDevice?: BaseDevice | null;
deepLinkPayload: unknown;
time?: number;
}): Action => ({
type: 'SELECT_PLUGIN',
payload: {...payload, time: payload.time ?? Date.now()},
});
export const selectClient = (clientId: string | null): Action => ({
type: 'SELECT_CLIENT',
payload: clientId,
});
export const setPluginEnabled = (pluginId: string, appId: string): Action => ({
type: 'SET_PLUGIN_ENABLED',
payload: {
pluginId,
selectedApp: appId,
},
});
export const setDevicePluginEnabled = (pluginId: string): Action => ({
type: 'SET_DEVICE_PLUGIN_ENABLED',
payload: {
pluginId,
},
});
export const setDevicePluginDisabled = (pluginId: string): Action => ({
type: 'SET_DEVICE_PLUGIN_DISABLED',
payload: {
pluginId,
},
});
export const setPluginDisabled = (pluginId: string, appId: string): Action => ({
type: 'SET_PLUGIN_DISABLED',
payload: {
pluginId,
selectedApp: appId,
},
});
export const appPluginListChanged = (): Action => ({
type: 'APP_PLUGIN_LIST_CHANGED',
});
export function getAvailableClients(
device: null | undefined | BaseDevice,
clients: Client[],
): Client[] {
if (!device) {
return [];
}
return clients
.filter(
(client: Client) =>
(device &&
device.supportsOS(client.query.os) &&
client.query.device_id === device.serial) ||
// Old android sdk versions don't know their device_id
// Display their plugins under all selected devices until they die out
client.query.device_id === 'unknown',
)
.sort((a, b) => (a.query.app || '').localeCompare(b.query.app));
}
function getBestAvailableClient(
device: BaseDevice | null | undefined,
clients: Client[],
preferredClient: string | null,
): Client | null {
const availableClients = getAvailableClients(device, clients);
if (availableClients.length === 0) {
return null;
}
return (
getClientById(availableClients, preferredClient) ||
availableClients[0] ||
null
);
}
export function getClientById(
clients: Client[],
clientId: string | null | undefined,
): Client | undefined {
return clients.find((client) => client.id === clientId);
}
export function canBeDefaultDevice(device: BaseDevice) {
return !DEFAULT_DEVICE_BLACKLIST.some(
(blacklistedDevice) => device instanceof blacklistedDevice,
);
}
/**
* This function, given the current state, tries to build to build the best
* selection possible, preselection device if there is non, plugins based on preferences, etc
* @param state
*/
function updateSelection(state: Readonly<State>): State {
if (state.staticView && state.staticView !== WelcomeScreenStaticView) {
return state;
}
const updates: Partial<State> = {
staticView: null,
};
// Find the selected device if it still exists
let device: BaseDevice | null =
state.selectedDevice && state.devices.includes(state.selectedDevice)
? state.selectedDevice
: null;
if (!device) {
device =
state.devices.find(
(device) => device.title === state.userPreferredDevice,
) ||
state.devices.find((device) => canBeDefaultDevice(device)) ||
null;
}
updates.selectedDevice = device;
if (!device) {
updates.staticView = WelcomeScreenStaticView;
}
// Select client based on device
const client = getBestAvailableClient(
device,
state.clients,
state.selectedApp || state.userPreferredApp,
);
updates.selectedApp = client ? client.id : null;
if (
// Try the preferred plugin first
state.userPreferredPlugin &&
state.userPreferredPlugin !== state.selectedPlugin
) {
updates.selectedPlugin = state.userPreferredPlugin;
} else if (
!state.selectedPlugin &&
state.enabledDevicePlugins.has(DEFAULT_PLUGIN)
) {
// currently selected plugin is not available in this state,
// fall back to the default
updates.selectedPlugin = DEFAULT_PLUGIN;
}
return {...state, ...updates};
}
export function getSelectedPluginKey(state: State): string | undefined {
return state.selectedPlugin
? getPluginKey(
state.selectedApp,
state.selectedDevice,
state.selectedPlugin,
)
: undefined;
}
export function isPluginEnabled(
enabledPlugins: State['enabledPlugins'],
enabledDevicePlugins: State['enabledDevicePlugins'],
app: string | null,
pluginId: string,
): boolean {
if (enabledDevicePlugins?.has(pluginId)) {
return true;
}
if (!app || !enabledPlugins) {
return false;
}
const appInfo = deconstructClientId(app);
const enabledAppPlugins = enabledPlugins[appInfo.app];
return enabledAppPlugins && enabledAppPlugins.indexOf(pluginId) > -1;
}
// TODO: remove!
export function destroyDevice(store: Store, logger: Logger, serial: string) {
const device = store
.getState()
.connections.devices.find((device) => device.serial === serial);
if (device) {
device.destroy();
logger.track('usage', 'unregister-device', {
os: device.os,
serial,
});
store.dispatch({
type: 'UNREGISTER_DEVICES',
payload: new Set([serial]),
});
}
}