Files
flipper/desktop/app/src/reducers/connections.tsx
Michel Weststrate 60ddad7e08 Fix deeplink parsing issue and device plugin highlighting
Summary:
This diff fixes an issue where the selected device was not correctly updated when selecting a device plugin, causing the wrong plugin, or none at all, to be highlighted.

This diff fixes an issue where deeplinks were not correctly parsed if formatted as shown in the test plan of D20920587 (it was a different format in the recording, but after looking into it, using `plugin?params` instead of `plugin/?params` is the natural thing to do, so we now support both)

Reviewed By: jknoxville

Differential Revision: D21154190

fbshipit-source-id: c31132aaf7213ee1c3c188a6a1cf2b6bcc7b5b00
2020-04-21 06:33:25 -07:00

663 lines
17 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 {produce} from 'immer';
import BaseDevice from '../devices/BaseDevice';
import MacDevice from '../devices/MacDevice';
import Client from '../Client';
import {UninitializedClient} from '../UninitializedClient';
import {isEqual} from 'lodash';
import iosUtil from '../fb-stubs/iOSContainerUtility';
import {performance} from 'perf_hooks';
import isHeadless from '../utils/isHeadless';
import {Actions} from '.';
const WelcomeScreen = isHeadless()
? require('../chrome/WelcomeScreenHeadless').default
: require('../chrome/WelcomeScreen').default;
import NotificationScreen from '../chrome/NotificationScreen';
import SupportRequestFormV2 from '../fb-stubs/SupportRequestFormV2';
import SupportRequestDetails from '../fb-stubs/SupportRequestDetails';
import {getPluginKey} from '../utils/pluginUtils';
import {deconstructClientId} from '../utils/clientUtils';
import {FlipperDevicePlugin} from '../plugin';
import {RegisterPluginAction} from './plugins';
export type StaticView =
| null
| typeof WelcomeScreen
| typeof NotificationScreen
| typeof SupportRequestFormV2
| typeof SupportRequestDetails;
export type FlipperError = {
occurrences?: number;
message: string;
details?: string;
error?: Error | string;
urgent?: boolean; // if true this error should always popup up
};
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[]};
errors: FlipperError[];
clients: Array<Client>;
uninitializedClients: Array<{
client: UninitializedClient;
deviceId?: string;
errorMessage?: string;
}>;
deepLinkPayload: string | null;
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: null | string;
selectedDevice?: null | BaseDevice;
time: number;
};
}
| {
type: 'SELECT_USER_PREFERRED_PLUGIN';
payload: string;
}
| {
type: 'SERVER_ERROR';
payload: null | FlipperError;
}
| {
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: 'CLIENT_SETUP_ERROR';
payload: {client: UninitializedClient; error: FlipperError};
}
| {
type: 'SET_STATIC_VIEW';
payload: StaticView;
}
| {
type: 'DISMISS_ERROR';
payload: number;
}
| {
type: 'STAR_PLUGIN';
payload: {
selectedPlugin: string;
selectedApp: string;
};
}
| {
type: 'SELECT_CLIENT';
payload: string;
}
| {
type: 'SET_DEEPLINK_PAYLOAD';
payload: null | string;
}
| RegisterPluginAction;
const DEFAULT_PLUGIN = 'DeviceLogs';
const DEFAULT_DEVICE_BLACKLIST = [MacDevice];
const INITAL_STATE: State = {
devices: [],
androidEmulators: [],
selectedDevice: null,
selectedApp: null,
selectedPlugin: DEFAULT_PLUGIN,
userPreferredDevice: null,
userPreferredPlugin: null,
userPreferredApp: null,
userStarredPlugins: {},
errors: [],
clients: [],
uninitializedClients: [],
deepLinkPayload: null,
staticView: WelcomeScreen,
};
const reducer = (state: State = INITAL_STATE, action: Actions): State => {
switch (action.type) {
case 'SET_STATIC_VIEW': {
const {payload} = action;
const {selectedPlugin} = state;
return {
...state,
staticView: payload,
selectedPlugin: payload != null ? null : selectedPlugin,
};
}
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}`,
);
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) => !deviceSerials.has(device.serial),
);
}),
);
}
case 'SELECT_PLUGIN': {
const {payload} = action;
const {selectedPlugin, selectedApp, deepLinkPayload} = payload;
let selectedDevice = payload.selectedDevice;
if (deepLinkPayload) {
const deepLinkParams = new URLSearchParams(deepLinkPayload || '');
const deviceParam = deepLinkParams.get('device');
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 (!selectDevice) {
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
? selectedDevice.title
: state.userPreferredDevice,
deepLinkPayload: deepLinkPayload,
});
}
case 'STAR_PLUGIN': {
const {selectedPlugin, selectedApp} = action.payload;
return produce(state, (draft) => {
if (!draft.userStarredPlugins[selectedApp]) {
draft.userStarredPlugins[selectedApp] = [selectedPlugin];
} else {
const plugins = draft.userStarredPlugins[selectedApp];
const idx = plugins.indexOf(selectedPlugin);
if (idx === -1) {
plugins.push(selectedPlugin);
} else {
plugins.splice(idx, 1);
}
}
});
}
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 'SERVER_ERROR': {
const {payload} = action;
if (!payload) {
return state;
}
return {...state, errors: mergeError(state.errors, payload)};
}
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 'CLIENT_SETUP_ERROR': {
const {payload} = action;
const errorMessage =
payload.error instanceof Error
? payload.error.message
: '' + payload.error;
const details = `Client setup error: ${errorMessage} while setting up client: ${payload.client.os}:${payload.client.deviceName}:${payload.client.appName}`;
console.error(details);
return {
...state,
uninitializedClients: state.uninitializedClients
.map((c) =>
isEqual(c.client, payload.client)
? {...c, errorMessage: errorMessage}
: c,
)
.sort((a, b) => a.client.appName.localeCompare(b.client.appName)),
errors: mergeError(state.errors, {
message: `Client setup error: ${errorMessage}`,
details,
error: payload.error instanceof Error ? payload.error : undefined,
}),
};
}
case 'DISMISS_ERROR': {
const errors = state.errors.slice();
errors.splice(action.payload, 1);
return {
...state,
errors,
};
}
case 'SET_DEEPLINK_PAYLOAD': {
return {...state, deepLinkPayload: action.payload};
}
case 'REGISTER_PLUGINS': {
// plugins are registered after creating the base devices, so update them
const plugins = action.payload;
plugins.forEach((plugin) => {
if (plugin.prototype instanceof FlipperDevicePlugin) {
// smell: devices are mutable
state.devices.forEach((device) => {
// @ts-ignore
if (plugin.supportsDevice(device)) {
device.devicePlugins = [
...(device.devicePlugins || []),
plugin.id,
];
}
});
}
});
return state;
}
default:
return state;
}
};
export default (state: State = INITAL_STATE, action: Actions): State => {
const nextState = reducer(state, action);
if (nextState.selectedDevice) {
const {selectedDevice} = nextState;
const deviceNotSupportedErrorMessage = 'iOS Devices are not yet supported';
const error =
selectedDevice.os === 'iOS' &&
selectedDevice.deviceType === 'physical' &&
!iosUtil.isAvailable()
? deviceNotSupportedErrorMessage
: null;
if (error) {
const deviceNotSupportedError = nextState.errors.find(
(error) => error.message === deviceNotSupportedErrorMessage,
);
if (deviceNotSupportedError) {
deviceNotSupportedError.message = error;
} else {
nextState.errors.push({message: error});
}
}
}
return nextState;
};
function mergeError(
errors: FlipperError[],
newError: FlipperError,
): FlipperError[] {
const idx = errors.findIndex((error) => error.message === newError.message);
const results = errors.slice();
if (idx !== -1) {
results[idx] = {
...newError,
occurrences: (errors[idx].occurrences || 0) + 1,
};
} else {
results.push({
...newError,
occurrences: 1,
});
}
return results;
}
export const selectDevice = (payload: BaseDevice): Action => ({
type: 'SELECT_DEVICE',
payload,
});
export const setStaticView = (payload: StaticView): Action => {
if (!payload) {
throw new Error('Cannot set empty static view');
}
return {
type: 'SET_STATIC_VIEW',
payload,
};
};
export const preferDevice = (payload: string): Action => ({
type: 'PREFER_DEVICE',
payload,
});
export const selectPlugin = (payload: {
selectedPlugin: null | string;
selectedApp?: null | string;
selectedDevice?: BaseDevice | null;
deepLinkPayload: null | string;
time?: number;
}): Action => ({
type: 'SELECT_PLUGIN',
payload: {...payload, time: payload.time ?? Date.now()},
});
export const starPlugin = (payload: {
selectedPlugin: string;
selectedApp: string;
}): Action => ({
type: 'STAR_PLUGIN',
payload,
});
export const dismissError = (index: number): Action => ({
type: 'DISMISS_ERROR',
payload: index,
});
export const selectClient = (clientId: string): Action => ({
type: 'SELECT_CLIENT',
payload: clientId,
});
export const setDeeplinkPayload = (payload: string | null): Action => ({
type: 'SET_DEEPLINK_PAYLOAD',
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 !== WelcomeScreen) {
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 = WelcomeScreen;
}
// 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;
}