diff --git a/desktop/app/src/dispatcher/__tests__/handleOpenPluginDeeplink.node.tsx b/desktop/app/src/dispatcher/__tests__/handleOpenPluginDeeplink.node.tsx index 397e56683..81fcc710d 100644 --- a/desktop/app/src/dispatcher/__tests__/handleOpenPluginDeeplink.node.tsx +++ b/desktop/app/src/dispatcher/__tests__/handleOpenPluginDeeplink.node.tsx @@ -18,9 +18,11 @@ import { usePlugin, createState, useValue, + DevicePluginClient, } from 'flipper-plugin'; import {parseOpenPluginParams} from '../handleOpenPluginDeeplink'; import {handleDeeplink} from '../../deeplink'; +import {selectPlugin} from '../../reducers/connections'; test('open-plugin deeplink parsing', () => { const testpayload = 'http://www.google/?test=c o%20o+l'; @@ -118,3 +120,156 @@ test('Triggering a deeplink will work', async () => { `); }); + +test('triggering a deeplink without applicable device can wait for a device', async () => { + let lastOS: string = ''; + const definition = TestUtils.createTestDevicePlugin( + { + Component() { + return

Hello

; + }, + devicePlugin(c: DevicePluginClient) { + lastOS = c.device.os; + return {}; + }, + }, + { + id: 'DevicePlugin', + supportedDevices: [{os: 'iOS'}], + }, + ); + const {renderer, store, createDevice} = await renderMockFlipperWithPlugin( + definition, + ); + + store.dispatch( + selectPlugin({selectedPlugin: 'nonexisting', deepLinkPayload: null}), + ); + expect(renderer.baseElement).toMatchInlineSnapshot(` + +
+ + `); + + const handlePromise = handleDeeplink( + store, + `flipper://open-plugin?plugin-id=${definition.id}&devices=iOS`, + ); + + jest.runAllTimers(); + + // No device yet available (dialogs are not renderable atm) + expect(renderer.baseElement).toMatchInlineSnapshot(` + +
+ + `); + + // create a new device + createDevice({serial: 'device2', os: 'iOS'}); + + // wizard should continue automatically + await handlePromise; + expect(renderer.baseElement).toMatchInlineSnapshot(` + +
+
+
+
+
+

+ Hello +

+
+
+
+
+
+ + `); + + expect(lastOS).toBe('iOS'); +}); + +test('triggering a deeplink without applicable client can wait for a device', async () => { + const definition = TestUtils.createTestPlugin( + { + Component() { + return

Hello

; + }, + plugin() { + return {}; + }, + }, + { + id: 'pluggy', + }, + ); + const {renderer, store, createClient, device} = + await renderMockFlipperWithPlugin(definition); + + store.dispatch( + selectPlugin({selectedPlugin: 'nonexisting', deepLinkPayload: null}), + ); + expect(renderer.baseElement).toMatchInlineSnapshot(` + +
+ + `); + + const handlePromise = handleDeeplink( + store, + `flipper://open-plugin?plugin-id=${definition.id}&client=clienty`, + ); + + jest.runAllTimers(); + + // No device yet available (dialogs are not renderable atm) + expect(renderer.baseElement).toMatchInlineSnapshot(` + +
+ + `); + + // create a new client + createClient(device, 'clienty'); + + // wizard should continue automatically + await handlePromise; + expect(renderer.baseElement).toMatchInlineSnapshot(` + +
+
+
+
+
+

+ Hello +

+
+
+
+
+
+
+ + `); +}); diff --git a/desktop/app/src/dispatcher/handleOpenPluginDeeplink.tsx b/desktop/app/src/dispatcher/handleOpenPluginDeeplink.tsx index e0fb80e20..f7d40c087 100644 --- a/desktop/app/src/dispatcher/handleOpenPluginDeeplink.tsx +++ b/desktop/app/src/dispatcher/handleOpenPluginDeeplink.tsx @@ -15,15 +15,20 @@ import {checkForUpdate} from '../fb-stubs/checkForUpdate'; import {getAppVersion} from '../utils/info'; import {ACTIVE_SHEET_SIGN_IN, setActiveSheet} from '../reducers/application'; import {UserNotSignedInError} from '../utils/errors'; -import {selectPlugin} from '../reducers/connections'; +import {selectPlugin, setPluginEnabled} from '../reducers/connections'; import {getUpdateAvailableMessage} from '../chrome/UpdateIndicator'; import {Typography} from 'antd'; import {getPluginStatus} from '../utils/pluginUtils'; import {loadPluginsFromMarketplace} from './fb-stubs/pluginMarketplace'; -import {loadPlugin} from '../reducers/pluginManager'; +import {loadPlugin, switchPlugin} from '../reducers/pluginManager'; import {startPluginDownload} from '../reducers/pluginDownloads'; import isProduction, {isTest} from '../utils/isProduction'; import restart from '../utils/restartFlipper'; +import BaseDevice from '../server/devices/BaseDevice'; +import Client from '../Client'; +import {Button} from 'antd'; +import {RocketOutlined} from '@ant-design/icons'; +import {showEmulatorLauncher} from '../sandy-chrome/appinspect/LaunchEmulator'; type OpenPluginParams = { pluginId: string; @@ -51,7 +56,7 @@ export function parseOpenPluginParams(query: string): OpenPluginParams { export async function handleOpenPluginDeeplink(store: Store, query: string) { const params = parseOpenPluginParams(query); - const title = `Starting plugin ${params.pluginId}…`; + const title = `Opening plugin ${params.pluginId}…`; if (!(await verifyLighthouseAndUserLoggedIn(store, title))) { return; @@ -60,10 +65,78 @@ export async function handleOpenPluginDeeplink(store: Store, query: string) { if (!(await verifyPluginStatus(store, params.pluginId, title))) { return; } - // await verifyDevices(); - // await verifyClient(); - // await verifyPluginEnabled(); - await openPlugin(store, params); + + 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, + ); + if (deviceOrClient === false) { + return; + } + const client: Client | undefined = isDevicePlugin + ? undefined + : (deviceOrClient as Client); + const device: BaseDevice = isDevicePlugin + ? (deviceOrClient as BaseDevice) + : (deviceOrClient as Client).deviceSync; + + // 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()}`, + }); + return; + } + if (!isDevicePlugin && !client!.plugins.has(params.pluginId)) { + await Dialog.alert({ + title, + type: 'error', + message: `This plugin is not supported by client ${client!.query.app}`, + }); + } + + // 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)); + } + + // open the plugin + if (isDevicePlugin) { + store.dispatch( + selectPlugin({ + selectedPlugin: params.pluginId, + selectedApp: null, + selectedDevice: device, + deepLinkPayload: params.payload, + }), + ); + } else { + store.dispatch( + selectPlugin({ + selectedPlugin: params.pluginId, + selectedApp: client!.query.app, + selectedDevice: device, + deepLinkPayload: params.payload, + }), + ); + } } // check if user is connected to VPN and logged in. Returns true if OK, or false if aborted @@ -325,12 +398,169 @@ async function installMarketPlacePlugin( return true; } -function openPlugin(store: Store, params: OpenPluginParams) { - store.dispatch( - selectPlugin({ - selectedApp: params.client, - selectedPlugin: params.pluginId, - deepLinkPayload: params.payload, - }), - ); +async function selectDevicesAndClient( + store: Store, + params: OpenPluginParams, + title: string, + isDevicePlugin: boolean, +): Promise { + 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), + ); + } + + // loop until we have devices (or abort) + while (!findValidDevices().length) { + if (!(await launchDeviceDialog(store, params, title))) { + return false; + } + } + + // at this point we have 1 or more valid devices + const availableDevices = findValidDevices(); + // device plugin + if (isDevicePlugin) { + if (availableDevices.length === 1) { + return availableDevices[0]; + } + return (await selectDeviceDialog(availableDevices, title)) ?? false; + } + + // wait for valid client + while (true) { + const validClients = store + .getState() + .connections.clients.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.deviceSync)); + + if (validClients.length === 1) { + return validClients[0]; + } + if (validClients.length > 1) { + return (await selectClientDialog(validClients, title)) ?? false; + } + + // no valid client yet + const result = await new Promise((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)); + + const origClients = store.getState().connections.clients; + // eslint-disable-next-line promise/catch-or-return + waitFor(store, (state) => state.connections.clients !== origClients).then( + () => { + dialog.close(); + resolve(true); + }, + ); + }); + + if (!result) { + return false; // 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((resolve) => { + const dialog = Dialog.alert({ + title, + type: 'warning', + message: ( +

+ 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{' '} +