Files
flipper/desktop/flipper-ui-core/src/dispatcher/handleOpenPluginDeeplink.tsx
Michel Weststrate fa67c21def Fix bug where client plugins weren't always started
Summary:
Changelog: Fixed issue where occasionally a plugin wouldn't open after starting Flipper

This fixes a long standing issue where rarely Flipper wouldn't show the selected plugin. This turned out to be a raise condition, that was easy to reproduce in the Flipper browser version; if a client register before all the plugins are loaded, the plugins that are enabled for that client, but not loaded yet, will not instantiate and hence not show up. This diff fixes that

Reviewed By: timur-valiev, aigoncharov

Differential Revision: D32987162

fbshipit-source-id: f3179cd9b6f2e4e79d05be1f2236f63acdf50495
2021-12-10 17:59:33 -08:00

643 lines
19 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 React from 'react';
import {Dialog, getFlipperLib} from 'flipper-plugin';
import {isTest} from 'flipper-common';
import {getUser} from '../fb-stubs/user';
import {Store} from '../reducers/index';
import {checkForUpdate} from '../fb-stubs/checkForUpdate';
import {getAppVersion} from '../utils/info';
import {UserNotSignedInError} from 'flipper-common';
import {
canBeDefaultDevice,
selectPlugin,
setPluginEnabled,
} from '../reducers/connections';
import {getUpdateAvailableMessage} from '../chrome/UpdateIndicator';
import {Typography} from 'antd';
import {getPluginStatus, PluginStatus} from '../utils/pluginUtils';
import {loadPluginsFromMarketplace} from './fb-stubs/pluginMarketplace';
import {loadPlugin, switchPlugin} from '../reducers/pluginManager';
import {startPluginDownload} from '../reducers/pluginDownloads';
import isProduction from '../utils/isProduction';
import BaseDevice from '../devices/BaseDevice';
import Client from '../Client';
import {RocketOutlined} from '@ant-design/icons';
import {showEmulatorLauncher} from '../sandy-chrome/appinspect/LaunchEmulator';
import {getAllClients} from '../reducers/connections';
import {showLoginDialog} from '../chrome/fb-stubs/SignInSheet';
import {
DeeplinkInteraction,
DeeplinkInteractionState,
OpenPluginParams,
} from '../deeplinkTracking';
import {getRenderHostInstance} from '../RenderHost';
import {waitFor} from '../utils/waitFor';
export function parseOpenPluginParams(query: string): OpenPluginParams {
// 'flipper://open-plugin?plugin-id=graphql&client=facebook&devices=android,ios&chrome=1&payload='
const url = new URL(query);
const params = new Map<string, string>(url.searchParams as any);
if (!params.has('plugin-id')) {
throw new Error('Missing plugin-id param');
}
return {
pluginId: params.get('plugin-id')!,
client: params.get('client'),
devices: params.get('devices')?.split(',') ?? [],
payload: params.get('payload')
? decodeURIComponent(params.get('payload')!)
: undefined,
};
}
export async function handleOpenPluginDeeplink(
store: Store,
query: string,
trackInteraction: (interaction: DeeplinkInteraction) => void,
) {
const params = parseOpenPluginParams(query);
const title = `Opening plugin ${params.pluginId}`;
console.debug(`[deeplink] ${title} for with params`, params);
if (!(await verifyLighthouseAndUserLoggedIn(store, title))) {
trackInteraction({
state: 'PLUGIN_LIGHTHOUSE_BAIL',
plugin: params,
});
return;
}
console.debug('[deeplink] Cleared Lighthouse and log-in check.');
await verifyFlipperIsUpToDate(title);
console.debug('[deeplink] Cleared up-to-date check.');
const [pluginStatusResult, pluginStatus] = await verifyPluginStatus(
store,
params.pluginId,
title,
);
if (!pluginStatusResult) {
trackInteraction({
state: 'PLUGIN_STATUS_BAIL',
plugin: params,
extra: {pluginStatus},
});
return;
}
console.debug('[deeplink] Cleared plugin status check:', pluginStatusResult);
const isDevicePlugin = store
.getState()
.plugins.devicePlugins.has(params.pluginId);
const pluginDefinition = isDevicePlugin
? store.getState().plugins.devicePlugins.get(params.pluginId)!
: store.getState().plugins.clientPlugins.get(params.pluginId)!;
const deviceOrClient = await selectDevicesAndClient(
store,
params,
title,
isDevicePlugin,
);
console.debug(
'[deeplink] Selected device and client:',
deviceOrClient instanceof BaseDevice
? deviceOrClient.description
: deviceOrClient instanceof Client
? deviceOrClient.query
: deviceOrClient,
);
if ('errorState' in deviceOrClient) {
trackInteraction({
state: deviceOrClient.errorState,
plugin: params,
});
return;
}
const client: Client | undefined = isDevicePlugin
? undefined
: (deviceOrClient as Client);
const device: BaseDevice = isDevicePlugin
? (deviceOrClient as BaseDevice)
: (deviceOrClient as Client).device;
console.debug('[deeplink] Client: ', client?.query);
console.debug('[deeplink] Device: ', device?.description);
// verify plugin supported by selected device / client
if (isDevicePlugin && !device.supportsPlugin(pluginDefinition)) {
await Dialog.alert({
title,
type: 'error',
message: `This plugin is not supported by device ${device.displayTitle()}`,
});
trackInteraction({
state: 'PLUGIN_DEVICE_UNSUPPORTED',
plugin: params,
extra: {device: device.displayTitle()},
});
return;
}
console.debug('[deeplink] Cleared device plugin support check.');
if (!isDevicePlugin && !client!.plugins.has(params.pluginId)) {
await Dialog.alert({
title,
type: 'error',
message: `This plugin is not supported by client ${client!.query.app}`,
});
trackInteraction({
state: 'PLUGIN_CLIENT_UNSUPPORTED',
plugin: params,
extra: {client: client!.query.app},
});
return;
}
console.debug('[deeplink] Cleared client plugin support check.');
// verify plugin enabled
if (isDevicePlugin) {
// for the device plugins enabling is a bit more complication and should go through the pluginManager
if (
!store.getState().connections.enabledDevicePlugins.has(params.pluginId)
) {
store.dispatch(switchPlugin({plugin: pluginDefinition}));
}
} else {
store.dispatch(setPluginEnabled(params.pluginId, client!.query.app));
}
console.debug('[deeplink] Cleared plugin enabling.');
// open the plugin
if (isDevicePlugin) {
store.dispatch(
selectPlugin({
selectedPlugin: params.pluginId,
selectedAppId: null,
selectedDevice: device,
deepLinkPayload: params.payload,
}),
);
} else {
store.dispatch(
selectPlugin({
selectedPlugin: params.pluginId,
selectedAppId: client!.id,
selectedDevice: device,
deepLinkPayload: params.payload,
}),
);
}
trackInteraction({
state: 'PLUGIN_OPEN_SUCCESS',
plugin: params,
});
}
// check if user is connected to VPN and logged in. Returns true if OK, or false if aborted
async function verifyLighthouseAndUserLoggedIn(
store: Store,
title: string,
): Promise<boolean> {
if (
!getFlipperLib().isFB ||
getRenderHostInstance().serverConfig.env.NODE_ENV === 'test'
) {
return true; // ok, continue
}
// repeat until connection succeeded
while (true) {
const spinnerDialog = Dialog.loading({
title,
message: 'Checking connection to Facebook Intern',
});
try {
const user = await getUser();
spinnerDialog.close();
// User is logged in
if (user) {
return true;
} else {
// Connected, but not logged in or no valid profile object returned
return await showPleaseLoginDialog(store, title);
}
} catch (e) {
spinnerDialog.close();
if (e instanceof UserNotSignedInError) {
// connection, but user is not logged in
return await showPleaseLoginDialog(store, title);
}
// General connection error.
// Not connected (to presumably) intern at all
if (
!(await Dialog.confirm({
title,
message:
'It looks you are currently not connected to Lighthouse / VPN. Please connect and retry.',
okText: 'Retry',
}))
) {
return false;
}
}
}
}
async function showPleaseLoginDialog(
store: Store,
title: string,
): Promise<boolean> {
if (
!(await Dialog.confirm({
title,
message: 'You are currently not logged in, please login.',
okText: 'Login',
}))
) {
// cancelled login
return false;
}
await showLoginDialog();
// wait until login succeeded
await waitForLogin(store);
return true;
}
async function waitForLogin(store: Store) {
return waitFor(store, (state) => !!state.user?.id);
}
async function verifyFlipperIsUpToDate(title: string) {
if (!isProduction() || isTest()) {
return;
}
const currentVersion = getAppVersion();
const handle = Dialog.loading({
title,
message: 'Checking if Flipper is up-to-date',
});
try {
const result = await checkForUpdate(currentVersion);
handle.close();
switch (result.kind) {
case 'error':
// if we can't tell if we're up to date, we don't want to halt the process on that.
console.warn('Failed to verify Flipper version', result);
return;
case 'up-to-date':
return;
case 'update-available':
await Dialog.confirm({
title,
message: (
<Typography.Text>
{getUpdateAvailableMessage(result)}
</Typography.Text>
),
okText: 'Skip',
});
return;
}
} catch (e) {
// if we can't tell if we're up to date, we don't want to halt the process on that.
console.warn('Failed to verify Flipper version', e);
handle.close();
}
}
async function verifyPluginStatus(
store: Store,
pluginId: string,
title: string,
): Promise<[boolean, PluginStatus]> {
// make sure we have marketplace plugin data present
if (!isTest() && !store.getState().plugins.marketplacePlugins.length) {
// plugins not yet fetched
// updates plugins from marketplace (if logged in), and stores them
await loadPluginsFromMarketplace();
}
// while true loop; after pressing install or add GK, we want to check again if plugin is available
while (true) {
const [status, reason] = getPluginStatus(store, pluginId);
switch (status) {
case 'ready':
return [true, status];
case 'unknown':
await Dialog.alert({
type: 'warning',
title,
message: `No plugin with id '${pluginId}' is known to Flipper. Please correct the deeplink, or install the plugin from NPM using the plugin manager.`,
});
return [false, status];
case 'failed':
await Dialog.alert({
type: 'error',
title,
message: `We found plugin '${pluginId}', but failed to load it: ${reason}. Please check the logs for more details`,
});
return [false, status];
case 'gatekeeped':
if (
!(await Dialog.confirm({
title,
message: (
<p>
{`To use plugin '${pluginId}', it is necessary to be a member of the GK '${reason}'. Click `}
<Typography.Link
href={`https://www.internalfb.com/intern/gatekeeper/projects/${reason}`}>
here
</Typography.Link>{' '}
to enroll, restart Flipper, and click the link again.
</p>
),
okText: 'Restart',
onConfirm: async () => {
getRenderHostInstance().restartFlipper();
// intentionally forever pending, we're restarting...
return new Promise(() => {});
},
}))
) {
return [false, status];
}
break;
case 'bundle_installable': {
// For convenience, don't ask user to install bundled plugins, handle it directly
await installBundledPlugin(store, pluginId, title);
break;
}
case 'marketplace_installable': {
if (!(await installMarketPlacePlugin(store, pluginId, title))) {
return [false, status];
}
break;
}
default:
throw new Error('Unhandled state: ' + status);
}
}
}
async function installBundledPlugin(
store: Store,
pluginId: string,
title: string,
) {
const plugin = store.getState().plugins.bundledPlugins.get(pluginId);
if (!plugin || !plugin.isBundled) {
throw new Error(`Failed to find bundled plugin '${pluginId}'`);
}
const loadingDialog = Dialog.loading({
title,
message: `Loading plugin '${pluginId}'...`,
});
store.dispatch(loadPlugin({plugin, enable: true, notifyIfFailed: true}));
try {
await waitFor(
store,
() => getPluginStatus(store, pluginId)[0] !== 'bundle_installable',
);
} finally {
loadingDialog.close();
}
}
async function installMarketPlacePlugin(
store: Store,
pluginId: string,
title: string,
): Promise<boolean> {
if (
!(await Dialog.confirm({
title,
message: `The requested plugin '${pluginId}' is currently not installed, but can be downloaded from the Flipper plugin Marketplace. If you trust the source of the current link, press 'Install' to continue`,
okText: 'Install',
}))
) {
return false;
}
const plugin = store
.getState()
.plugins.marketplacePlugins.find((p) => p.id === pluginId);
if (!plugin) {
throw new Error(`Failed to find marketplace plugin '${pluginId}'`);
}
const loadingDialog = Dialog.loading({
title,
message: `Installing plugin '${pluginId}'...`,
});
try {
store.dispatch(startPluginDownload({plugin, startedByUser: true}));
await waitFor(
store,
() => getPluginStatus(store, pluginId)[0] !== 'marketplace_installable',
);
} finally {
loadingDialog.close();
}
return true;
}
type DeeplinkError = {
errorState: DeeplinkInteractionState;
};
async function selectDevicesAndClient(
store: Store,
params: OpenPluginParams,
title: string,
isDevicePlugin: boolean,
): Promise<DeeplinkError | BaseDevice | Client> {
function findValidDevices() {
// find connected devices with the right OS.
return (
store
.getState()
.connections.devices.filter((d) => d.connected.get())
.filter(
(d) => params.devices.length === 0 || params.devices.includes(d.os),
)
// This filters out OS-level devices which are causing more confusion than good
// when used with deeplinks.
.filter(canBeDefaultDevice)
);
}
// loop until we have devices (or abort)
while (!findValidDevices().length) {
if (!(await launchDeviceDialog(store, params, title))) {
return {errorState: 'PLUGIN_DEVICE_BAIL'};
}
}
// at this point we have 1 or more valid devices
const availableDevices = findValidDevices();
console.debug(
'[deeplink] selectDevicesAndClient found at least one more valid device:',
availableDevices.map((d) => d.description),
);
// device plugin
if (isDevicePlugin) {
if (availableDevices.length === 1) {
return availableDevices[0];
}
const selectedDevice = await selectDeviceDialog(availableDevices, title);
if (!selectedDevice) {
return {errorState: 'PLUGIN_DEVICE_SELECTION_BAIL'};
}
return selectedDevice;
}
console.debug('[deeplink] Not a device plugin. Waiting for valid client.');
// wait for valid client
while (true) {
const origClients = store.getState().connections.clients;
const validClients = getAllClients(store.getState().connections)
.filter(
// correct app name, or, if not set, an app that at least supports this plugin
(c) =>
params.client
? c.query.app === params.client
: c.plugins.has(params.pluginId),
)
.filter((c) => c.connected.get())
.filter((c) => availableDevices.includes(c.device));
if (validClients.length === 1) {
return validClients[0];
}
if (validClients.length > 1) {
const selectedClient = await selectClientDialog(validClients, title);
if (!selectedClient) {
return {errorState: 'PLUGIN_CLIENT_SELECTION_BAIL'};
}
return selectedClient;
}
// no valid client yet
const result = await new Promise<boolean>((resolve) => {
const dialog = Dialog.alert({
title,
type: 'warning',
message: params.client
? `Application '${params.client}' doesn't seem to be connected yet. Please start a debug version of the app to continue.`
: `No application that supports plugin '${params.pluginId}' seems to be running. Please start a debug application that supports the plugin to continue.`,
okText: 'Cancel',
});
// eslint-disable-next-line promise/catch-or-return
dialog.then(() => resolve(false));
// eslint-disable-next-line promise/catch-or-return
waitFor(store, (state) => state.connections.clients !== origClients).then(
() => {
dialog.close();
resolve(true);
},
);
// We also want to react to changes in the available plugins and refresh.
origClients.forEach((c) =>
c.on('plugins-change', () => {
dialog.close();
resolve(true);
}),
);
});
if (!result) {
return {errorState: 'PLUGIN_CLIENT_BAIL'}; // User cancelled
}
}
}
/**
* Shows a warning that no device was found, with button to launch emulator.
* Resolves false if cancelled, or true if new devices were detected.
*/
async function launchDeviceDialog(
store: Store,
params: OpenPluginParams,
title: string,
) {
return new Promise<boolean>((resolve) => {
const currentDevices = store.getState().connections.devices;
const waitForNewDevice = async () =>
await waitFor(
store,
(state) => state.connections.devices !== currentDevices,
);
const dialog = Dialog.confirm({
title,
message: (
<p>
To open the current deeplink for plugin {params.pluginId} a device{' '}
{params.devices.length ? ' of type ' + params.devices.join(', ') : ''}{' '}
should be up and running. No device was found. Please connect a device
or launch an emulator / simulator.
</p>
),
cancelText: 'Cancel',
okText: 'Launch Device',
onConfirm: async () => {
showEmulatorLauncher(store);
await waitForNewDevice();
return true;
},
okButtonProps: {
icon: <RocketOutlined />,
},
});
// eslint-disable-next-line promise/catch-or-return
dialog.then(() => {
// dialog was cancelled
resolve(false);
});
// new devices were found
// eslint-disable-next-line promise/catch-or-return
waitForNewDevice().then(() => {
dialog.close();
resolve(true);
});
});
}
async function selectDeviceDialog(
devices: BaseDevice[],
title: string,
): Promise<undefined | BaseDevice> {
const selectedId = await Dialog.options({
title,
message: 'Select the device to open:',
options: devices.map((d) => ({
value: d.serial,
label: d.displayTitle(),
})),
});
// might find nothing if id === false
return devices.find((d) => d.serial === selectedId);
}
async function selectClientDialog(
clients: Client[],
title: string,
): Promise<undefined | Client> {
const selectedId = await Dialog.options({
title,
message:
'Multiple applications running this plugin were found, please select one:',
options: clients.map((c) => ({
value: c.id,
label: `${c.query.app} on ${c.device.displayTitle()}`,
})),
});
// might find nothing if id === false
return clients.find((c) => c.id === selectedId);
}