From 12a72c5af887feac9b23e3e40756f84bff3c642d Mon Sep 17 00:00:00 2001 From: Carlos Garcia Ortiz karliatto Date: Thu, 19 Sep 2024 09:58:08 +0200 Subject: [PATCH] feat(connect-deeplink): create package --- packages/connect-deeplink/README.md | 47 ++++ packages/connect-deeplink/package.json | 17 ++ packages/connect-deeplink/src/index.ts | 210 ++++++++++++++++++ packages/connect-deeplink/tsconfig.json | 8 + .../module-connect-popup/package.json | 1 + .../components/ConnectPopupDebugOptions.tsx | 117 +++++----- .../src/screens/ConnectPopupScreen.tsx | 4 +- .../src/utils/buildURL.ts | 5 - .../module-connect-popup/tsconfig.json | 5 +- yarn.lock | 1 + 10 files changed, 344 insertions(+), 71 deletions(-) create mode 100644 packages/connect-deeplink/README.md create mode 100644 packages/connect-deeplink/package.json create mode 100644 packages/connect-deeplink/src/index.ts create mode 100644 packages/connect-deeplink/tsconfig.json delete mode 100644 suite-native/module-connect-popup/src/utils/buildURL.ts diff --git a/packages/connect-deeplink/README.md b/packages/connect-deeplink/README.md new file mode 100644 index 00000000000..8cbe34e6448 --- /dev/null +++ b/packages/connect-deeplink/README.md @@ -0,0 +1,47 @@ +# @trezor/connect-deeplink + +[![NPM](https://img.shields.io/npm/v/@trezor/connect-deeplink.svg)](https://www.npmjs.org/package/@trezor/connect-deeplink) + +The `@trezor/connect-deeplink` package provides an implementation of `@trezor/connect` which uses deep links to communicate with the Trezor Suite Lite app. + +Currently the library is still under development, only supports read-only methods and does not communicate with the production Suite Lite app. + +To run a dev version of the Suite mobile app follow the instructions in [@suite-native/app](https://github.com/trezor/trezor-suite/blob/develop/suite-native/app/README.md) + + +## Using the Library + +To use the library, you need to initialize it with the `deeplinkOpen` and `deeplinkCallbackUrl` settings. + +```javascript +import TrezorConnect from '@trezor/connect-deeplink'; + +TrezorConnect.init({ + manifest: { + email: 'developer@xyz.com', + appUrl: 'http://your.application.com', + }, + deeplinkOpen: url => { + // eslint-disable-next-line no-console + console.log('deeplinkOpen', url); + Linking.openURL(url); + }, + deeplinkCallbackUrl: Linking.createURL('/connect'), +}); +``` + +To receive the deep link callback, you need to add a listener which will call `TrezorConnect.handleDeeplink` with the deep link URL. + +```javascript +useEffect(() => { + const subscription = Linking.addEventListener('url', event => { + TrezorConnect.handleDeeplink(event.url); + }); + + return () => subscription?.remove(); +}, []); +``` + +## Example + +The [connect-deeplink-example](https://github.com/trezor/trezor-suite/tree/develop/packages/connect-examples/connect-deeplink-example) shows how to use the library in a React Native + Expo app. diff --git a/packages/connect-deeplink/package.json b/packages/connect-deeplink/package.json new file mode 100644 index 00000000000..0329d95bc88 --- /dev/null +++ b/packages/connect-deeplink/package.json @@ -0,0 +1,17 @@ +{ + "name": "@trezor/connect-deeplink", + "version": "1.0.0", + "private": true, + "license": "See LICENSE.md in repo root", + "sideEffects": false, + "main": "src/index", + "scripts": { + "depcheck": "yarn g:depcheck", + "lint:js": "yarn g:eslint '**/*.{ts,tsx,js}'", + "type-check": "yarn g:tsc --build" + }, + "dependencies": { + "@trezor/connect": "workspace:^", + "@trezor/utils": "workspace:^" + } +} diff --git a/packages/connect-deeplink/src/index.ts b/packages/connect-deeplink/src/index.ts new file mode 100644 index 00000000000..8b97a504c14 --- /dev/null +++ b/packages/connect-deeplink/src/index.ts @@ -0,0 +1,210 @@ +import EventEmitter from 'events'; + +import * as ERRORS from '@trezor/connect/src/constants/errors'; +import { parseConnectSettings } from '@trezor/connect/src/data/connectSettings'; +import type { CallMethodPayload } from '@trezor/connect/src/events/call'; +import { ConnectFactoryDependencies, factory } from '@trezor/connect/src/factory'; +import type { TrezorConnect as TrezorConnectType } from '@trezor/connect/src/types'; +import type { + ConnectSettings, + ConnectSettingsPublic, + Manifest, + Response, +} from '@trezor/connect/src/types'; +import { Login } from '@trezor/connect/src/types/api/requestLogin'; +import { Deferred, createDeferred } from '@trezor/utils'; + +export class TrezorConnectDeeplink implements ConnectFactoryDependencies { + public eventEmitter = new EventEmitter(); + private _settings: ConnectSettings; + private messagePromises: Record> = {}; + private messageID = 0; + private schema = 'trezorsuitelite'; + + public constructor() { + this._settings = { + ...parseConnectSettings(), + deeplinkOpen: () => { + throw ERRORS.TypedError('Init_NotInitialized'); + }, + }; + } + + public manifest(manifest: Manifest) { + this._settings = { + ...this._settings, + ...parseConnectSettings({ + ...this._settings, + manifest, + }), + }; + } + + public init(settings: Partial) { + if (!settings.deeplinkOpen) { + throw new Error('TrezorConnect native requires "deeplinkOpen" setting.'); + } + this._settings = { + ...parseConnectSettings({ ...this._settings, ...settings }), + deeplinkOpen: settings.deeplinkOpen, + deeplinkCallbackUrl: settings.deeplinkCallbackUrl, + }; + + return Promise.resolve(); + } + + public call(params: CallMethodPayload) { + this.messageID++; + this.messagePromises[this.messageID] = createDeferred(); + const { method, ...restParams } = params; + if (!this._settings) { + throw new Error('TrezorConnect not initialized.'); + } + if (!this._settings.deeplinkOpen) { + throw new Error('TrezorConnect native requires "deeplinkOpen" setting.'); + } + if (!this._settings.deeplinkCallbackUrl) { + throw new Error('TrezorConnect native requires "deeplinkCallbackUrl" setting.'); + } + const callbackUrl = this.buildCallbackUrl(this._settings.deeplinkCallbackUrl, { + id: this.messageID, + }); + const url = this.buildUrl(method, restParams, callbackUrl); + this._settings.deeplinkOpen(url); + + return this.messagePromises[this.messageID].promise; + } + + public requestLogin(): Response { + throw ERRORS.TypedError('Method_InvalidPackage'); + } + + public uiResponse() { + throw ERRORS.TypedError('Method_InvalidPackage'); + } + + public renderWebUSBButton() {} + + public disableWebUSB() { + throw ERRORS.TypedError('Method_InvalidPackage'); + } + + public requestWebUSBDevice() { + throw ERRORS.TypedError('Method_InvalidPackage'); + } + + public cancel(error?: string) { + this.resolveMessagePromises({ + success: false, + error, + }); + } + + public dispose() { + this.eventEmitter.removeAllListeners(); + this._settings = parseConnectSettings(); + + return Promise.resolve(undefined); + } + + public handleDeeplink(url: string): void { + let id; + let parsedUrl; + try { + parsedUrl = new URL(url); + id = parsedUrl.searchParams.get('id'); + if (!id || isNaN(Number(id))) throw new Error('Missing `id` parameter.'); + id = Number(id); + } catch (error) { + this.resolveMessagePromises({ + success: false, + error, + }); + + return; + } + + const responseParam = parsedUrl.searchParams.get('response'); + if (!responseParam) { + this.messagePromises[id].resolve({ + id, + success: false, + error: 'The provided url is missing `response` parameter.', + }); + delete this.messagePromises[id]; + + return; + } + + let parsedParams; + try { + parsedParams = JSON.parse(responseParam); + } catch {} + + if (!parsedParams) { + this.messagePromises[id].resolve({ + id, + success: false, + error: 'Error parsing deeplink params.', + }); + delete this.messagePromises[id]; + } + + const { success, payload } = parsedParams; + this.messagePromises[id].resolve({ id, payload, success }); + delete this.messagePromises[id]; + } + + resolveMessagePromises(resolvePayload: Record) { + Object.keys(this.messagePromises).forEach(id => { + this.messagePromises[id as any].resolve({ + id, + payload: resolvePayload, + }); + delete this.messagePromises[id as any]; + }); + } + + private buildUrl(method: string, params: any, callback: string) { + return `${this.schema}://connect?method=${method}¶ms=${encodeURIComponent( + JSON.stringify(params), + )}&callback=${encodeURIComponent(callback)}`; + } + + private buildCallbackUrl(url: string, params: Record) { + try { + const urlWithParams = new URL(url); + Object.entries(params).forEach(([key, value]) => { + urlWithParams.searchParams.set(key, value.toString()); + }); + + return urlWithParams.toString(); + } catch { + throw new Error('Provided "deeplinkCallbackUrl" is not valid.'); + } + } +} + +const impl = new TrezorConnectDeeplink(); +const TrezorConnect: TrezorConnectType & { + handleDeeplink: (url: string) => void; +} = { + ...factory({ + eventEmitter: impl.eventEmitter, + init: impl.init.bind(impl), + call: impl.call.bind(impl), + manifest: impl.manifest.bind(impl), + requestLogin: impl.requestLogin.bind(impl), + uiResponse: impl.uiResponse.bind(impl), + renderWebUSBButton: impl.renderWebUSBButton.bind(impl), + disableWebUSB: impl.disableWebUSB.bind(impl), + requestWebUSBDevice: impl.requestWebUSBDevice.bind(impl), + cancel: impl.cancel.bind(impl), + dispose: impl.dispose.bind(impl), + }), + handleDeeplink: impl.handleDeeplink.bind(impl), +}; + +// eslint-disable-next-line import/no-default-export +export default TrezorConnect; +export * from '@trezor/connect/src/exports'; diff --git a/packages/connect-deeplink/tsconfig.json b/packages/connect-deeplink/tsconfig.json new file mode 100644 index 00000000000..5f414508676 --- /dev/null +++ b/packages/connect-deeplink/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "libDev" }, + "references": [ + { "path": "../connect" }, + { "path": "../utils" } + ] +} diff --git a/suite-native/module-connect-popup/package.json b/suite-native/module-connect-popup/package.json index e207c0c30f8..a77ea4a05d9 100644 --- a/suite-native/module-connect-popup/package.json +++ b/suite-native/module-connect-popup/package.json @@ -21,6 +21,7 @@ "@suite-native/intl": "workspace:^", "@suite-native/navigation": "workspace:*", "@trezor/connect": "workspace:*", + "@trezor/connect-deeplink": "workspace:*", "expo-linking": "^6.3.1", "react": "18.2.0", "react-native": "0.75.2", diff --git a/suite-native/module-connect-popup/src/components/ConnectPopupDebugOptions.tsx b/suite-native/module-connect-popup/src/components/ConnectPopupDebugOptions.tsx index 2285298d87f..f0ab00e5825 100644 --- a/suite-native/module-connect-popup/src/components/ConnectPopupDebugOptions.tsx +++ b/suite-native/module-connect-popup/src/components/ConnectPopupDebugOptions.tsx @@ -1,10 +1,9 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import * as Linking from 'expo-linking'; import { BottomSheet, Button } from '@suite-native/atoms'; - -import { buildURL } from '../utils/buildURL'; +import TrezorConnectDeeplink from '@trezor/connect-deeplink'; type ConnectPopupDebugOptionsProps = React.PropsWithChildren<{ showDebug: boolean; @@ -16,6 +15,19 @@ export const ConnectPopupDebugOptions = ({ setShowDebug, children, }: ConnectPopupDebugOptionsProps) => { + useEffect(() => { + TrezorConnectDeeplink.init({ + manifest: { + email: 'info@trezor.io', + appUrl: '@suite-native/app', + }, + deeplinkCallbackUrl: 'https://httpbin.org/get', + deeplinkOpen: url => { + Linking.openURL(url); + }, + }); + }, []); + return ( { - Linking.openURL( - buildURL( - 'getAddress', - { - path: "m/49'/0'/0'/0/0", - coin: 'btc', - }, - 'https://httpbin.org/get', - ), - ); + TrezorConnectDeeplink.getAddress({ + path: "m/49'/0'/0'/0/0", + coin: 'btc', + }); }} > getAddress test