handle device / client absence for deeplinks

Summary:
This diff makes the new deeplink format feature complete, make sure VPN connection, plugin installation, client & device selection are now all handled. See the test plan for examples.

Changelog: Flipper now supports a richer protocol for opening deeplinks: https://fbflipper.com/docs/extending/deeplinks#open-plugin

Reviewed By: timur-valiev

Differential Revision: D30423809

fbshipit-source-id: e6cf4bf852b2c64e9a79a33ef0842eb27f68f840
This commit is contained in:
Michel Weststrate
2021-08-20 02:23:05 -07:00
committed by Facebook GitHub Bot
parent 846246ffae
commit a2644b4a2e
7 changed files with 415 additions and 26 deletions

View File

@@ -18,9 +18,11 @@ import {
usePlugin, usePlugin,
createState, createState,
useValue, useValue,
DevicePluginClient,
} from 'flipper-plugin'; } from 'flipper-plugin';
import {parseOpenPluginParams} from '../handleOpenPluginDeeplink'; import {parseOpenPluginParams} from '../handleOpenPluginDeeplink';
import {handleDeeplink} from '../../deeplink'; import {handleDeeplink} from '../../deeplink';
import {selectPlugin} from '../../reducers/connections';
test('open-plugin deeplink parsing', () => { test('open-plugin deeplink parsing', () => {
const testpayload = 'http://www.google/?test=c o%20o+l'; const testpayload = 'http://www.google/?test=c o%20o+l';
@@ -118,3 +120,156 @@ test('Triggering a deeplink will work', async () => {
</body> </body>
`); `);
}); });
test('triggering a deeplink without applicable device can wait for a device', async () => {
let lastOS: string = '';
const definition = TestUtils.createTestDevicePlugin(
{
Component() {
return <p>Hello</p>;
},
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(`
<body>
<div />
</body>
`);
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(`
<body>
<div />
</body>
`);
// create a new device
createDevice({serial: 'device2', os: 'iOS'});
// wizard should continue automatically
await handlePromise;
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
<div
class="css-1x2cmzz-SandySplitContainer e1hsqii10"
>
<div />
<div
class="css-1knrt0j-SandySplitContainer e1hsqii10"
>
<div
class="css-1woty6b-Container"
>
<p>
Hello
</p>
</div>
<div
class="css-724x97-View-FlexBox-FlexRow"
id="detailsSidebar"
/>
</div>
</div>
</div>
</body>
`);
expect(lastOS).toBe('iOS');
});
test('triggering a deeplink without applicable client can wait for a device', async () => {
const definition = TestUtils.createTestPlugin(
{
Component() {
return <p>Hello</p>;
},
plugin() {
return {};
},
},
{
id: 'pluggy',
},
);
const {renderer, store, createClient, device} =
await renderMockFlipperWithPlugin(definition);
store.dispatch(
selectPlugin({selectedPlugin: 'nonexisting', deepLinkPayload: null}),
);
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div />
</body>
`);
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(`
<body>
<div />
</body>
`);
// create a new client
createClient(device, 'clienty');
// wizard should continue automatically
await handlePromise;
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
<div
class="css-1x2cmzz-SandySplitContainer e1hsqii10"
>
<div />
<div
class="css-1knrt0j-SandySplitContainer e1hsqii10"
>
<div
class="css-1woty6b-Container"
>
<p>
Hello
</p>
</div>
<div
class="css-724x97-View-FlexBox-FlexRow"
id="detailsSidebar"
/>
</div>
</div>
</div>
<div />
</body>
`);
});

View File

@@ -15,15 +15,20 @@ import {checkForUpdate} from '../fb-stubs/checkForUpdate';
import {getAppVersion} from '../utils/info'; import {getAppVersion} from '../utils/info';
import {ACTIVE_SHEET_SIGN_IN, setActiveSheet} from '../reducers/application'; import {ACTIVE_SHEET_SIGN_IN, setActiveSheet} from '../reducers/application';
import {UserNotSignedInError} from '../utils/errors'; import {UserNotSignedInError} from '../utils/errors';
import {selectPlugin} from '../reducers/connections'; import {selectPlugin, setPluginEnabled} from '../reducers/connections';
import {getUpdateAvailableMessage} from '../chrome/UpdateIndicator'; import {getUpdateAvailableMessage} from '../chrome/UpdateIndicator';
import {Typography} from 'antd'; import {Typography} from 'antd';
import {getPluginStatus} from '../utils/pluginUtils'; import {getPluginStatus} from '../utils/pluginUtils';
import {loadPluginsFromMarketplace} from './fb-stubs/pluginMarketplace'; import {loadPluginsFromMarketplace} from './fb-stubs/pluginMarketplace';
import {loadPlugin} from '../reducers/pluginManager'; import {loadPlugin, switchPlugin} from '../reducers/pluginManager';
import {startPluginDownload} from '../reducers/pluginDownloads'; import {startPluginDownload} from '../reducers/pluginDownloads';
import isProduction, {isTest} from '../utils/isProduction'; import isProduction, {isTest} from '../utils/isProduction';
import restart from '../utils/restartFlipper'; 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 = { type OpenPluginParams = {
pluginId: string; pluginId: string;
@@ -51,7 +56,7 @@ export function parseOpenPluginParams(query: string): OpenPluginParams {
export async function handleOpenPluginDeeplink(store: Store, query: string) { export async function handleOpenPluginDeeplink(store: Store, query: string) {
const params = parseOpenPluginParams(query); const params = parseOpenPluginParams(query);
const title = `Starting plugin ${params.pluginId}`; const title = `Opening plugin ${params.pluginId}`;
if (!(await verifyLighthouseAndUserLoggedIn(store, title))) { if (!(await verifyLighthouseAndUserLoggedIn(store, title))) {
return; return;
@@ -60,10 +65,78 @@ export async function handleOpenPluginDeeplink(store: Store, query: string) {
if (!(await verifyPluginStatus(store, params.pluginId, title))) { if (!(await verifyPluginStatus(store, params.pluginId, title))) {
return; return;
} }
// await verifyDevices();
// await verifyClient(); const isDevicePlugin = store
// await verifyPluginEnabled(); .getState()
await openPlugin(store, params); .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 // 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; return true;
} }
function openPlugin(store: Store, params: OpenPluginParams) { async function selectDevicesAndClient(
store.dispatch( store: Store,
selectPlugin({ params: OpenPluginParams,
selectedApp: params.client, title: string,
selectedPlugin: params.pluginId, isDevicePlugin: boolean,
deepLinkPayload: params.payload, ): Promise<false | 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),
);
}
// 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<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));
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<boolean>((resolve) => {
const dialog = Dialog.alert({
title,
type: 'warning',
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{' '}
<Button
icon={<RocketOutlined />}
title="Start Emulator / Simulator"
onClick={() => {
showEmulatorLauncher(store);
}}
size="small"
/>
.
</p>
),
okText: 'Cancel',
});
// eslint-disable-next-line promise/catch-or-return
dialog.then(() => {
// dialog was canceled
resolve(false);
});
const currentDevices = store.getState().connections.devices;
// new devices were found
// eslint-disable-next-line promise/catch-or-return
waitFor(
store,
(state) => state.connections.devices !== currentDevices,
).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.deviceSync.displayTitle()}`,
})),
});
// might find nothing if id === false
return clients.find((c) => c.id === selectedId);
} }

View File

@@ -8,7 +8,7 @@
*/ */
import {createStore} from 'redux'; import {createStore} from 'redux';
import BaseDevice from '../server/devices/BaseDevice'; import BaseDevice, {OS} from '../server/devices/BaseDevice';
import {createRootReducer} from '../reducers'; import {createRootReducer} from '../reducers';
import {Store} from '../reducers/index'; import {Store} from '../reducers/index';
import Client, {ClientQuery} from '../Client'; import Client, {ClientQuery} from '../Client';
@@ -43,6 +43,7 @@ export interface DeviceOptions {
serial?: string; serial?: string;
isSupportedByPlugin?: (p: PluginDetails) => boolean; isSupportedByPlugin?: (p: PluginDetails) => boolean;
archived?: boolean; archived?: boolean;
os?: OS;
} }
export default class MockFlipper { export default class MockFlipper {
@@ -117,6 +118,7 @@ export default class MockFlipper {
serial, serial,
isSupportedByPlugin, isSupportedByPlugin,
archived, archived,
os,
}: DeviceOptions = {}): BaseDevice { }: DeviceOptions = {}): BaseDevice {
const s = serial ?? `serial_${++this._deviceCounter}`; const s = serial ?? `serial_${++this._deviceCounter}`;
const device = archived const device = archived
@@ -126,7 +128,7 @@ export default class MockFlipper {
title: 'archived device', title: 'archived device',
os: 'Android', os: 'Android',
}) })
: new BaseDevice(s, 'physical', 'MockAndroidDevice', 'Android'); : new BaseDevice(s, 'physical', 'MockAndroidDevice', os ?? 'Android');
device.supportsPlugin = !isSupportedByPlugin device.supportsPlugin = !isSupportedByPlugin
? () => true ? () => true
: isSupportedByPlugin; : isSupportedByPlugin;

View File

@@ -45,7 +45,7 @@ export type MockFlipperResult = {
pluginKey: string; pluginKey: string;
sendError(error: any, client?: Client): void; sendError(error: any, client?: Client): void;
sendMessage(method: string, params: any, client?: Client): void; sendMessage(method: string, params: any, client?: Client): void;
createDevice(serial: string): BaseDevice; createDevice(options: Parameters<MockFlipper['createDevice']>[0]): BaseDevice;
createClient( createClient(
device: BaseDevice, device: BaseDevice,
name: string, name: string,
@@ -127,8 +127,8 @@ export async function createMockFlipperWithPlugin(
const logger = mockFlipper.logger; const logger = mockFlipper.logger;
const store = mockFlipper.store; const store = mockFlipper.store;
const createDevice = (serial: string, archived?: boolean) => const createDevice = (options: Parameters<MockFlipper['createDevice']>[0]) =>
mockFlipper.createDevice({serial, archived}); mockFlipper.createDevice(options);
const createClient = async ( const createClient = async (
device: BaseDevice, device: BaseDevice,
name: string, name: string,
@@ -171,7 +171,7 @@ export async function createMockFlipperWithPlugin(
const device = options?.device const device = options?.device
? mockFlipper.loadDevice(options?.device) ? mockFlipper.loadDevice(options?.device)
: createDevice('serial', options?.archivedDevice); : createDevice({serial: 'serial', archived: options?.archivedDevice});
const client = await createClient(device, 'TestApp'); const client = await createClient(device, 'TestApp');
store.dispatch(selectDevice(device)); store.dispatch(selectDevice(device));

View File

@@ -295,7 +295,7 @@ test('queue - events are queued for plugins that are favorite when app is select
selectDeviceLogs(store); selectDeviceLogs(store);
expect(store.getState().connections.selectedPlugin).not.toBe('TestPlugin'); expect(store.getState().connections.selectedPlugin).not.toBe('TestPlugin');
const device2 = createDevice('serial2'); const device2 = createDevice({serial: 'serial2'});
const client2 = await createClient(device2, client.query.app); // same app id const client2 = await createClient(device2, client.query.app); // same app id
store.dispatch(selectDevice(device2)); store.dispatch(selectDevice(device2));
store.dispatch(selectClient(client2.id)); store.dispatch(selectClient(client2.id));

View File

@@ -72,7 +72,7 @@ export const Dialog = {
}} }}
okButtonProps={{ okButtonProps={{
disabled: opts.onValidate disabled: opts.onValidate
? opts.onValidate(currentValue) !== '' ? !!opts.onValidate(currentValue) // non-falsy value means validation error
: false, : false,
}} }}
onCancel={cancel} onCancel={cancel}
@@ -175,11 +175,12 @@ export const Dialog = {
message: React.ReactNode; message: React.ReactNode;
options: {label: string; value: string}[]; options: {label: string; value: string}[];
onConfirm?: (value: string) => Promise<string>; onConfirm?: (value: string) => Promise<string>;
}): DialogResult<string> { }): DialogResult<string | false> {
return Dialog.show<string>({ return Dialog.show<string>({
...rest, ...rest,
defaultValue: '', defaultValue: undefined as any,
onValidate: (value) => (value === '' ? 'Please select an option' : ''), onValidate: (value) =>
value === undefined ? 'Please select an option' : '',
children: (value, onChange) => ( children: (value, onChange) => (
<Layout.Container gap style={{maxHeight: '50vh', overflow: 'auto'}}> <Layout.Container gap style={{maxHeight: '50vh', overflow: 'auto'}}>
<Typography.Text>{message}</Typography.Text> <Typography.Text>{message}</Typography.Text>

View File

@@ -17,6 +17,7 @@ import {render, unmountComponentAtNode} from 'react-dom';
export function renderReactRoot( export function renderReactRoot(
handler: (unmount: () => void) => React.ReactElement, handler: (unmount: () => void) => React.ReactElement,
): () => void { ): () => void {
// TODO: find a way to make this visible in unit tests as well
const div = document.body.appendChild(document.createElement('div')); const div = document.body.appendChild(document.createElement('div'));
const unmount = () => { const unmount = () => {
unmountComponentAtNode(div); unmountComponentAtNode(div);