diff --git a/desktop/app/src/MenuBar.tsx b/desktop/app/src/MenuBar.tsx index dfbbad388..fc350f606 100644 --- a/desktop/app/src/MenuBar.tsx +++ b/desktop/app/src/MenuBar.tsx @@ -34,6 +34,7 @@ import { import {StyleGuide} from './sandy-chrome/StyleGuide'; import {showEmulatorLauncher} from './sandy-chrome/appinspect/LaunchEmulator'; import {webFrame} from 'electron'; +import {openDeeplinkDialog} from './deeplink'; export type DefaultKeyboardAction = keyof typeof _buildInMenuEntries; export type TopLevelMenu = 'Edit' | 'View' | 'Window' | 'Help'; @@ -364,6 +365,12 @@ function getTemplate( } }, }, + { + label: 'Trigger deeplink...', + click() { + openDeeplinkDialog(store); + }, + }, { type: 'separator', }, diff --git a/desktop/app/src/__tests__/deeplink.node.tsx b/desktop/app/src/__tests__/deeplink.node.tsx new file mode 100644 index 000000000..3c4999d90 --- /dev/null +++ b/desktop/app/src/__tests__/deeplink.node.tsx @@ -0,0 +1,95 @@ +/** + * 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 + */ + +jest.useFakeTimers(); + +import React from 'react'; +import {renderMockFlipperWithPlugin} from '../test-utils/createMockFlipperWithPlugin'; +import { + _SandyPluginDefinition, + PluginClient, + TestUtils, + usePlugin, + createState, + useValue, +} from 'flipper-plugin'; +import {handleDeeplink} from '../deeplink'; + +test('Triggering a deeplink will work', async () => { + const linksSeen: any[] = []; + + const plugin = (client: PluginClient) => { + const linkState = createState(''); + client.onDeepLink((link) => { + linksSeen.push(link); + linkState.set(String(link)); + }); + return { + linkState, + }; + }; + + const definition = new _SandyPluginDefinition( + TestUtils.createMockPluginDetails(), + { + plugin, + Component() { + const instance = usePlugin(plugin); + const linkState = useValue(instance.linkState); + return

{linkState || 'world'}

; + }, + }, + ); + const {renderer, client, store} = await renderMockFlipperWithPlugin( + definition, + ); + + expect(linksSeen).toEqual([]); + + await handleDeeplink( + store, + `flipper://${client.query.app}/${definition.id}/universe`, + ); + + jest.runAllTimers(); + expect(linksSeen).toEqual(['universe']); + expect(renderer.baseElement).toMatchInlineSnapshot(` + +
+
+
+
+
+

+ universe +

+
+
+
+
+
+ + `); +}); + +test('Will throw error on invalid deeplinks', async () => { + // flipper:///support-form/?form=litho + expect(() => + handleDeeplink(undefined as any, `flipper://test`), + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Unknown deeplink"`); +}); diff --git a/desktop/app/src/deeplink.tsx b/desktop/app/src/deeplink.tsx new file mode 100644 index 000000000..5125a1981 --- /dev/null +++ b/desktop/app/src/deeplink.tsx @@ -0,0 +1,146 @@ +/** + * 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 { + ACTIVE_SHEET_SIGN_IN, + setActiveSheet, + setPastedToken, + toggleAction, +} from './reducers/application'; +import {Group, SUPPORTED_GROUPS} from './reducers/supportForm'; +import {Store} from './reducers/index'; +import {importDataToStore} from './utils/exportData'; +import {selectPlugin} from './reducers/connections'; +import {Layout, renderReactRoot} from 'flipper-plugin'; +import React, {useState} from 'react'; +import {Alert, Input, Modal} from 'antd'; + +const UNKNOWN = 'Unknown deeplink'; +/** + * Handle a flipper:// deeplink. Will throw if the URL pattern couldn't be recognised + */ +export async function handleDeeplink( + store: Store, + query: string, +): Promise { + const uri = new URL(query); + if (uri.protocol !== 'flipper:') { + throw new Error(UNKNOWN); + } + if (uri.pathname.match(/^\/*import\/*$/)) { + const url = uri.searchParams.get('url'); + store.dispatch(toggleAction('downloadingImportData', true)); + if (url) { + return fetch(url) + .then((res) => res.text()) + .then((data) => importDataToStore(url, data, store)) + .then(() => { + store.dispatch(toggleAction('downloadingImportData', false)); + }) + .catch((e: Error) => { + console.error('Failed to download Flipper trace' + e); + store.dispatch(toggleAction('downloadingImportData', false)); + throw e; + }); + } + throw new Error(UNKNOWN); + } else if (uri.pathname.match(/^\/*support-form\/*$/)) { + const formParam = uri.searchParams.get('form'); + const grp = deeplinkFormParamToGroups(formParam); + if (grp) { + grp.handleSupportFormDeeplinks(store); + return; + } + throw new Error(UNKNOWN); + } else if (uri.pathname.match(/^\/*login\/*$/)) { + const token = uri.searchParams.get('token'); + store.dispatch(setPastedToken(token ?? undefined)); + if (store.getState().application.activeSheet !== ACTIVE_SHEET_SIGN_IN) { + store.dispatch(setActiveSheet(ACTIVE_SHEET_SIGN_IN)); + } + return; + } + const match = uriComponents(query); + if (match.length > 1) { + // flipper://// + store.dispatch( + selectPlugin({ + selectedApp: match[0], + selectedPlugin: match[1], + deepLinkPayload: match[2], + }), + ); + return; + } else { + throw new Error(UNKNOWN); + } +} + +function deeplinkFormParamToGroups( + formParam: string | null, +): Group | undefined { + if (!formParam) { + return undefined; + } + return SUPPORTED_GROUPS.find((grp) => { + return grp.deeplinkSuffix.toLowerCase() === formParam.toLowerCase(); + }); +} + +export const uriComponents = (url: string): Array => { + if (!url) { + return []; + } + const match: Array | undefined | null = url.match( + /^flipper:\/\/([^\/]*)\/([^\/\?]*)\/?(.*)$/, + ); + if (match) { + return match.map(decodeURIComponent).slice(1).filter(Boolean); + } + return []; +}; + +export function openDeeplinkDialog(store: Store) { + renderReactRoot((hide) => ); +} + +function TestDeeplinkDialog({ + store, + onHide, +}: { + store: Store; + onHide: () => void; +}) { + const [query, setQuery] = useState('flipper://'); + const [error, setError] = useState(''); + return ( + { + handleDeeplink(store, query) + .then(onHide) + .catch((e) => { + setError(`Failed to handle deeplink '${query}': ${e}`); + }); + }} + onCancel={onHide}> + + <>Enter a deeplink to test it: + { + setQuery(v.target.value); + }} + /> + {error && } + + + ); +} diff --git a/desktop/app/src/dispatcher/__tests__/deeplinkURLParsing.node.tsx b/desktop/app/src/dispatcher/__tests__/deeplinkURLParsing.node.tsx index a19150146..1d6113c2c 100644 --- a/desktop/app/src/dispatcher/__tests__/deeplinkURLParsing.node.tsx +++ b/desktop/app/src/dispatcher/__tests__/deeplinkURLParsing.node.tsx @@ -7,7 +7,7 @@ * @format */ -import {uriComponents} from '../application'; +import {uriComponents} from '../../deeplink'; test('test parsing of deeplink URL', () => { const url = 'flipper://app/plugin/meta/data'; diff --git a/desktop/app/src/dispatcher/application.tsx b/desktop/app/src/dispatcher/application.tsx index f90783620..c6e57c147 100644 --- a/desktop/app/src/dispatcher/application.tsx +++ b/desktop/app/src/dispatcher/application.tsx @@ -8,36 +8,16 @@ */ import {remote, ipcRenderer, IpcRendererEvent} from 'electron'; -import { - ACTIVE_SHEET_SIGN_IN, - setActiveSheet, - setPastedToken, - toggleAction, -} from '../reducers/application'; -import {Group, SUPPORTED_GROUPS} from '../reducers/supportForm'; import {Store} from '../reducers/index'; import {Logger} from '../fb-interfaces/Logger'; import {parseFlipperPorts} from '../utils/environmentVariables'; import { - importDataToStore, importFileToStore, IMPORT_FLIPPER_TRACE_EVENT, } from '../utils/exportData'; import {tryCatchReportPlatformFailures} from '../utils/metrics'; -import {selectPlugin} from '../reducers/connections'; - -export const uriComponents = (url: string): Array => { - if (!url) { - return []; - } - const match: Array | undefined | null = url.match( - /^flipper:\/\/([^\/]*)\/([^\/\?]*)\/?(.*)$/, - ); - if (match) { - return match.map(decodeURIComponent).slice(1).filter(Boolean); - } - return []; -}; +import {handleDeeplink} from '../deeplink'; +import {message} from 'antd'; export default (store: Store, _logger: Logger) => { const currentWindow = remote.getCurrentWindow(); @@ -77,66 +57,13 @@ export default (store: Store, _logger: Logger) => { ipcRenderer.on( 'flipper-protocol-handler', (_event: IpcRendererEvent, query: string) => { - const uri = new URL(query); - if (uri.protocol !== 'flipper:') { - return; - } - if (uri.pathname.match(/^\/*import\/*$/)) { - const url = uri.searchParams.get('url'); - store.dispatch(toggleAction('downloadingImportData', true)); - return ( - typeof url === 'string' && - fetch(url) - .then((res) => res.text()) - .then((data) => importDataToStore(url, data, store)) - .then(() => { - store.dispatch(toggleAction('downloadingImportData', false)); - }) - .catch((e: Error) => { - console.error(e); - store.dispatch(toggleAction('downloadingImportData', false)); - }) - ); - } else if (uri.pathname.match(/^\/*support-form\/*$/)) { - const formParam = uri.searchParams.get('form'); - const grp = deeplinkFormParamToGroups(formParam); - if (grp) { - grp.handleSupportFormDeeplinks(store); - } - return; - } else if (uri.pathname.match(/^\/*login\/*$/)) { - const token = uri.searchParams.get('token'); - store.dispatch(setPastedToken(token ?? undefined)); - if (store.getState().application.activeSheet !== ACTIVE_SHEET_SIGN_IN) { - store.dispatch(setActiveSheet(ACTIVE_SHEET_SIGN_IN)); - } - return; - } - const match = uriComponents(query); - if (match.length > 1) { - // flipper://// - return store.dispatch( - selectPlugin({ - selectedApp: match[0], - selectedPlugin: match[1], - deepLinkPayload: match[2], - }), - ); - } + handleDeeplink(store, query).catch((e) => { + console.warn('Failed to handle deeplink', query, e); + message.error(`Failed to handle deeplink '${query}': ${e}`); + }); }, ); - function deeplinkFormParamToGroups( - formParam: string | null, - ): Group | undefined { - if (!formParam) { - return undefined; - } - return SUPPORTED_GROUPS.find((grp) => { - return grp.deeplinkSuffix.toLowerCase() === formParam.toLowerCase(); - }); - } - ipcRenderer.on( 'open-flipper-file', (_event: IpcRendererEvent, url: string) => { diff --git a/desktop/types/index.d.ts b/desktop/types/index.d.ts index 3745ea231..7e73a0b7b 100644 --- a/desktop/types/index.d.ts +++ b/desktop/types/index.d.ts @@ -16,6 +16,7 @@ /// /// /// +/// /// /// /// @@ -26,4 +27,3 @@ /// /// /// -///