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
551 lines
14 KiB
TypeScript
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;
|
|
}
|