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
643 lines
19 KiB
TypeScript
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);
|
|
}
|