Files
flipper/desktop/app/src/reducers/connections.tsx
Michel Weststrate c43049d881 Preserve device state after disconnect
Summary:
This diff stack introduces support for keeping devices and clients around after they have disconnected. This is a pretty important debugging improvement, that will allow inspecting a device / app after it crashed for example.

This feature existed partially before, but only supported Android, and only support plugins with persisted state; as it replace the current device with an archived version of the same device. In practice this didn't work really well, as most plugins would not be available, and all non-persisted state would be lost.

This diff makes sure we can keep devices around after disconnecting, the next one will keep the clients around as well. And explain some code choices in more detail.

Note that `Device.isArchived` was an overloaded term before, and even more now (both representing imported and disconnected devices), will address that in a later diff.

https://github.com/facebook/flipper/issues/1460
https://github.com/facebook/flipper/issues/812
https://github.com/facebook/flipper/issues/1487

Changelog: iOS and Android devices will preserve their state after being disconnected

Reviewed By: nikoant

Differential Revision: D26224310

fbshipit-source-id: 7dfc93c2a109a51c2880ec212a00463bc8d32041
2021-02-09 04:16:24 -08:00

551 lines
14 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 '../devices/BaseDevice';
import MacDevice from '../devices/MacDevice';
import type Client from '../Client';
import type {UninitializedClient} from '../UninitializedClient';
import {isEqual} from 'lodash';
import {performance} from 'perf_hooks';
import type {Actions} from '.';
import {WelcomeScreenStaticView} from '../sandy-chrome/WelcomeScreen';
import {getPluginKey, isDevicePluginDefinition} from '../utils/pluginUtils';
import {deconstructClientId} from '../utils/clientUtils';
import type {PluginDefinition} from '../plugin';
import type {RegisterPluginAction} from './plugins';
import MetroDevice from '../devices/MetroDevice';
import {Logger} from 'flipper-plugin';
export type StaticViewProps = {logger: Logger};
export type StaticView =
| null
| ComponentType<StaticViewProps>
| React.FunctionComponent<any>;
export type State = {
devices: Array<BaseDevice>;
androidEmulators: Array<string>;
selectedDevice: null | BaseDevice;
selectedPlugin: null | string;
selectedApp: null | string;
userPreferredDevice: null | string;
userPreferredPlugin: null | string;
userPreferredApp: null | string;
userStarredPlugins: {[client: string]: string[]};
clients: Array<Client>;
uninitializedClients: Array<{
client: UninitializedClient;
deviceId?: string;
errorMessage?: string;
}>;
deepLinkPayload: unknown;
staticView: StaticView;
};
export type Action =
| {
type: 'UNREGISTER_DEVICES';
payload: Set<string>;
}
| {
type: 'REGISTER_DEVICE';
payload: BaseDevice;
}
| {
type: 'REGISTER_ANDROID_EMULATORS';
payload: Array<string>;
}
| {
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;
}
| {
// Implemented by rootReducer in `store.tsx`
type: 'STAR_PLUGIN';
payload: {
selectedApp: string;
plugin: PluginDefinition;
};
}
| {
type: 'SELECT_CLIENT';
payload: string | null;
}
| RegisterPluginAction
| {
// Implemented by rootReducer in `store.tsx`
type: 'UPDATE_PLUGIN';
payload: {
plugin: PluginDefinition;
enablePlugin: boolean;
};
};
const DEFAULT_PLUGIN = 'DeviceLogs';
const DEFAULT_DEVICE_BLACKLIST = [MacDevice, MetroDevice];
const INITAL_STATE: State = {
devices: [],
androidEmulators: [],
selectedDevice: null,
selectedApp: null,
selectedPlugin: DEFAULT_PLUGIN,
userPreferredDevice: null,
userPreferredPlugin: null,
userPreferredApp: null,
userStarredPlugins: {},
clients: [],
uninitializedClients: [],
deepLinkPayload: null,
staticView: WelcomeScreenStaticView,
};
export default (state: State = INITAL_STATE, action: Actions): State => {
switch (action.type) {
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_ANDROID_EMULATORS': {
const {payload} = action;
return {
...state,
androidEmulators: payload,
};
}
case 'REGISTER_DEVICE': {
const {payload} = action;
const newDevices = state.devices.slice();
const existing = state.devices.findIndex(
(device) => device.serial === payload.serial,
);
if (existing !== -1) {
console.debug(
`Got a new device instance for already existing serial ${payload.serial}`,
);
state.devices[existing].destroy();
newDevices[existing] = payload;
} else {
newDevices.push(payload);
}
return updateSelection({
...state,
devices: newDevices,
});
}
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 {
device.teardown();
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;
return updateSelection({
...state,
clients: state.clients.concat(payload),
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;
return updateSelection({
...state,
clients: state.clients.filter(
(client: Client) => client.id !== payload,
),
});
}
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;
}
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 starPlugin = (payload: {
plugin: PluginDefinition;
selectedApp: string;
}): Action => ({
type: 'STAR_PLUGIN',
payload,
});
export const selectClient = (clientId: string | null): Action => ({
type: 'SELECT_CLIENT',
payload: clientId,
});
export const registerPluginUpdate = (payload: {
plugin: PluginDefinition;
enablePlugin: boolean;
}): Action => ({
type: 'UPDATE_PLUGIN',
payload,
});
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 | undefined {
const availableClients = getAvailableClients(device, clients);
if (availableClients.length === 0) {
return undefined;
}
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;
const availablePlugins: string[] = [
...(device?.devicePlugins || []),
...(client?.plugins || []),
];
if (
// Try the preferred plugin first
state.userPreferredPlugin &&
availablePlugins.includes(state.userPreferredPlugin)
) {
updates.selectedPlugin = state.userPreferredPlugin;
} else if (
!state.selectedPlugin ||
!availablePlugins.includes(state.selectedPlugin)
) {
// 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 pluginIsStarred(
userStarredPlugins: State['userStarredPlugins'],
app: string | null,
pluginId: string,
): boolean {
if (!app) {
return false;
}
const appInfo = deconstructClientId(app);
const starred = userStarredPlugins[appInfo.app];
return starred && starred.indexOf(pluginId) > -1;
}