diff --git a/packages/blockchain-link-types/src/constants/errors.ts b/packages/blockchain-link-types/src/constants/errors.ts index 89f67f59c33..2c39bcabc9c 100644 --- a/packages/blockchain-link-types/src/constants/errors.ts +++ b/packages/blockchain-link-types/src/constants/errors.ts @@ -1,6 +1,7 @@ const PREFIX = 'blockchain_link/'; const ERROR: { [key: string]: string | undefined } = { + connect: undefined, worker_not_found: 'Worker not found', worker_invalid: 'Invalid worker object', worker_timeout: 'Worker timeout', @@ -19,23 +20,28 @@ export class CustomError extends Error { message = ''; - constructor(code: string, message = '') { + constructor(codeOrMessage: string, message = '') { // test reports that super is not covered, TODO: investigate more super(message); this.message = message; - if (typeof code === 'string') { - const c = - code.indexOf(PREFIX) === 0 ? code.substring(PREFIX.length, code.length) : code; - this.code = `${PREFIX}${c}`; - const msg = ERROR[c]; - if (typeof msg === 'string') { - if (this.message === '') { - this.message = msg; - } else if (message.indexOf('+') === 0) { - this.message = `${msg} ${message.substring(1)}`; + if (typeof codeOrMessage === 'string') { + const isPrefixed = codeOrMessage.indexOf(PREFIX) === 0; + const code = isPrefixed ? codeOrMessage.substring(PREFIX.length) : codeOrMessage; + const knownCode = Object.keys(ERROR).includes(code); + if (isPrefixed || knownCode) { + this.code = `${PREFIX}${code}`; + const codeMessage = ERROR[code]; + if (codeMessage) { + if (this.message === '') { + this.message = codeMessage; + } else if (message.indexOf('+') === 0) { + this.message = `${codeMessage} ${message.substring(1)}`; + } } + } else if (this.message === '') { + this.message = code; } } diff --git a/packages/blockchain-link/jest.config.unit.js b/packages/blockchain-link/jest.config.unit.js index f77f87cae1c..7f75fec4e3a 100644 --- a/packages/blockchain-link/jest.config.unit.js +++ b/packages/blockchain-link/jest.config.unit.js @@ -13,7 +13,6 @@ module.exports = { ...testPathIgnorePatterns, 'src/types', 'src/ui', - 'src/utils/ws.ts', 'fixtures', 'unit/worker/index.ts', ], diff --git a/packages/blockchain-link/package.json b/packages/blockchain-link/package.json index 1474f9973c8..f3cb702ec14 100644 --- a/packages/blockchain-link/package.json +++ b/packages/blockchain-link/package.json @@ -26,12 +26,10 @@ ], "main": "src/index.ts", "browser": { - "socks-proxy-agent": "./src/utils/socks-proxy-agent.ts", - "ws": "./src/utils/ws.ts" + "socks-proxy-agent": "./src/utils/socks-proxy-agent.ts" }, "react-native": { "__comment__": "Hotfix for issue where RN metro bundler resolve relatives paths wrong", - "ws": "@trezor/blockchain-link/src/utils/ws-native.ts", "socks-proxy-agent": "@trezor/blockchain-link/src/utils/socks-proxy-agent.ts" }, "publishConfig": { @@ -39,12 +37,10 @@ "types": "lib/index.d.ts", "typings": "lib/index.d.ts", "browser": { - "socks-proxy-agent": "./lib/utils/socks-proxy-agent.js", - "ws": "./lib/utils/ws.js" + "socks-proxy-agent": "./lib/utils/socks-proxy-agent.js" }, "react-native": { "__comment__": "Hotfix for issue where RN metro bundler resolve relatives paths wrong", - "ws": "@trezor/blockchain-link/lib/utils/ws-native.js", "socks-proxy-agent": "@trezor/blockchain-link/lib/utils/socks-proxy-agent.js" } }, @@ -83,11 +79,11 @@ "@trezor/env-utils": "workspace:*", "@trezor/utils": "workspace:*", "@trezor/utxo-lib": "workspace:*", + "@trezor/websocket-client": "workspace:*", "@types/web": "^0.0.174", "events": "^3.3.0", "ripple-lib": "^1.10.1", - "socks-proxy-agent": "8.0.4", - "ws": "^8.18.0" + "socks-proxy-agent": "8.0.4" }, "peerDependencies": { "tslib": "^2.6.2" diff --git a/packages/blockchain-link/src/workers/baseWebsocket.ts b/packages/blockchain-link/src/workers/baseWebsocket.ts index d33f0ced179..097bd59fdf8 100644 --- a/packages/blockchain-link/src/workers/baseWebsocket.ts +++ b/packages/blockchain-link/src/workers/baseWebsocket.ts @@ -1,8 +1,5 @@ -import WebSocket from 'ws'; - -import { createDeferred, createDeferredManager, TypedEmitter } from '@trezor/utils'; import { CustomError } from '@trezor/blockchain-link-types/src/constants/errors'; -import { TimerId } from '@trezor/type-utils'; +import { WebsocketClient, WebsocketRequest, WebsocketResponse } from '@trezor/websocket-client'; interface Subscription { id: string; @@ -10,106 +7,21 @@ interface Subscription { callback: (result: any) => void; } -interface Options { - url: string; - timeout?: number; - pingTimeout?: number; - connectionTimeout?: number; - keepAlive?: boolean; - agent?: WebSocket.ClientOptions['agent']; - headers?: WebSocket.ClientOptions['headers']; - onSending?: (message: Record) => void; -} - -const DEFAULT_TIMEOUT = 20 * 1000; -const DEFAULT_PING_TIMEOUT = 50 * 1000; - -type EventMap = { [event: string]: any }; - -type WsEvents = { - error: string; - disconnected: undefined; -}; - -export abstract class BaseWebsocket extends TypedEmitter { - readonly options: Options; - - private readonly messages; +export abstract class BaseWebsocket> extends WebsocketClient { private readonly subscriptions: Subscription[] = []; - private readonly emitter: TypedEmitter = this; - - private ws?: WebSocket; - private pingTimeout?: TimerId; - private connectPromise?: Promise; - - protected abstract ping(): Promise; - - protected abstract createWebsocket(): WebSocket; - - constructor(options: Options) { - super(); - this.options = options; - this.messages = createDeferredManager({ - timeout: this.options.timeout || DEFAULT_TIMEOUT, - onTimeout: this.onTimeout.bind(this), - }); - } - - private setPingTimeout() { - if (this.pingTimeout) { - clearTimeout(this.pingTimeout); - } - this.pingTimeout = setTimeout( - this.onPing.bind(this), - this.options.pingTimeout || DEFAULT_PING_TIMEOUT, - ); - } - private onTimeout() { - const { ws } = this; - if (!ws) return; - this.messages.rejectAll(new CustomError('websocket_timeout')); - ws.close(); - } - - private async onPing() { + async onPing() { // make sure that connection is alive if there are subscriptions - if (this.ws && this.isConnected()) { - try { - if (this.subscriptions.length > 0 || this.options.keepAlive) { - await this.ping(); - } else { - this.ws.close(); - } - } catch { - // empty - } + if (this.subscriptions.length > 0 || this.options.keepAlive) { + await this.ping(); + } else { + this.disconnect(); } } - private onError() { - this.onClose(); - } - - protected sendMessage(message: Record) { - const { ws } = this; - if (!ws) throw new CustomError('websocket_not_initialized'); - const { promiseId, promise } = this.messages.create(); - - const req = { id: promiseId.toString(), ...message }; - - this.setPingTimeout(); - - this.options.onSending?.(message); - - ws.send(JSON.stringify(req)); - - return promise; - } - - protected onMessage(message: string) { + protected onMessage(message: WebsocketResponse) { try { - const resp = JSON.parse(message); + const resp = JSON.parse(message.toString()); const { id, data } = resp; const messageSettled = data.error @@ -128,8 +40,12 @@ export abstract class BaseWebsocket extends TypedEmitter { + throw new CustomError(error.message); + }); } protected addSubscription(type: E, callback: (result: T[E]) => void) { @@ -146,95 +62,4 @@ export abstract class BaseWebsocket extends TypedEmitter(resolve => this.emitter.once('disconnected', resolve)); - } - - // create deferred promise - const dfd = createDeferred(-1); - this.connectPromise = dfd.promise; - - const ws = this.createWebsocket(); - - // set connection timeout before WebSocket initialization - const connectionTimeout = setTimeout( - () => { - ws.emit('error', new CustomError('websocket_timeout')); - try { - ws.once('error', () => {}); // hack; ws throws uncaughtably when there's no error listener - ws.close(); - } catch { - // empty - } - }, - this.options.connectionTimeout || this.options.timeout || DEFAULT_TIMEOUT, - ); - - ws.once('error', error => { - clearTimeout(connectionTimeout); - this.onClose(); - dfd.reject(new CustomError('websocket_runtime_error', error.message)); - }); - ws.on('open', () => { - clearTimeout(connectionTimeout); - this.init(); - dfd.resolve(); - }); - - this.ws = ws; - - // wait for onopen event - return dfd.promise.finally(() => { - this.connectPromise = undefined; - }); - } - - private init() { - const { ws } = this; - if (!ws || !this.isConnected()) { - throw Error('Websocket init cannot be called'); - } - - // remove previous listeners and add new listeners - ws.removeAllListeners(); - ws.on('error', this.onError.bind(this)); - ws.on('message', this.onMessage.bind(this)); - ws.on('close', () => { - this.onClose(); - }); - } - - disconnect() { - this.emitter.emit('disconnected'); - this.ws?.close(); - } - - isConnected() { - return this.ws?.readyState === WebSocket.OPEN; - } - - private onClose() { - if (this.pingTimeout) { - clearTimeout(this.pingTimeout); - } - - this.disconnect(); - - this.ws?.removeAllListeners(); - this.messages.rejectAll( - new CustomError('websocket_runtime_error', 'Websocket closed unexpectedly'), - ); - } - - dispose() { - this.removeAllListeners(); - this.onClose(); - } } diff --git a/packages/blockchain-link/src/workers/blockbook/websocket.ts b/packages/blockchain-link/src/workers/blockbook/websocket.ts index 77a74f11a30..521f434d632 100644 --- a/packages/blockchain-link/src/workers/blockbook/websocket.ts +++ b/packages/blockchain-link/src/workers/blockbook/websocket.ts @@ -1,5 +1,3 @@ -import WebSocket from 'ws'; - import { CustomError } from '@trezor/blockchain-link-types/src/constants/errors'; import type { BlockNotification, @@ -48,9 +46,8 @@ export class BlockbookAPI extends BaseWebsocket { url += suffix; } - // initialize connection, - // options are not used in web builds (see ./src/utils/ws) - return new WebSocket(url, { + return this.initWebsocket({ + url, agent: this.options.agent, headers: { Origin: 'https://node.trezor.io', diff --git a/packages/blockchain-link/src/workers/blockfrost/websocket.ts b/packages/blockchain-link/src/workers/blockfrost/websocket.ts index 274477224e1..9e42fa61e7c 100644 --- a/packages/blockchain-link/src/workers/blockfrost/websocket.ts +++ b/packages/blockchain-link/src/workers/blockfrost/websocket.ts @@ -1,5 +1,3 @@ -import WebSocket from 'ws'; - import type { Send, BlockContent, @@ -23,8 +21,8 @@ export class BlockfrostAPI extends BaseWebsocket { protected createWebsocket() { const { url } = this.options; - // options are not used in web builds (see ./src/utils/ws) - return new WebSocket(url, { + return this.initWebsocket({ + url, agent: this.options.agent, headers: { Origin: 'https://node.trezor.io', diff --git a/packages/blockchain-link/tests/unit/errors.test.ts b/packages/blockchain-link/tests/unit/errors.test.ts index e1fe2e17b1b..63870d4ae66 100644 --- a/packages/blockchain-link/tests/unit/errors.test.ts +++ b/packages/blockchain-link/tests/unit/errors.test.ts @@ -19,16 +19,16 @@ describe('Custom errors', () => { expect(error.message).toBe('Worker not found'); }); - it('Error with custom code and custom message', () => { + it('Error with custom prefixed code and custom message', () => { const error = new CustomError('blockchain_link/custom', 'Custom message'); expect(error.code).toBe('blockchain_link/custom'); expect(error.message).toBe('Custom message'); }); - it('Error with custom code and without message', () => { + it('Error with custom code as message', () => { const error = new CustomError('custom'); - expect(error.code).toBe('blockchain_link/custom'); - expect(error.message).toBe('Message not set'); + expect(error.code).toBe(undefined); + expect(error.message).toBe('custom'); }); it('Error without code and with custom message', () => { diff --git a/packages/blockchain-link/tsconfig.json b/packages/blockchain-link/tsconfig.json index e13bab8d4a8..5da12e617ae 100644 --- a/packages/blockchain-link/tsconfig.json +++ b/packages/blockchain-link/tsconfig.json @@ -12,6 +12,7 @@ { "path": "../env-utils" }, { "path": "../utils" }, { "path": "../utxo-lib" }, + { "path": "../websocket-client" }, { "path": "../e2e-utils" }, { "path": "../eslint" }, { "path": "../type-utils" } diff --git a/packages/blockchain-link/tsconfig.lib.json b/packages/blockchain-link/tsconfig.lib.json index 428254ad4b5..047d43b974a 100644 --- a/packages/blockchain-link/tsconfig.lib.json +++ b/packages/blockchain-link/tsconfig.lib.json @@ -22,6 +22,9 @@ { "path": "../utxo-lib" }, + { + "path": "../websocket-client" + }, { "path": "../e2e-utils" }, diff --git a/packages/blockchain-link/webpack/dev.js b/packages/blockchain-link/webpack/dev.js index 2d583b3dcb4..d7436b3991a 100644 --- a/packages/blockchain-link/webpack/dev.js +++ b/packages/blockchain-link/webpack/dev.js @@ -66,7 +66,6 @@ module.exports = { Buffer: ['buffer', 'Buffer'], process: 'process/browser', }), - new webpack.NormalModuleReplacementPlugin(/^ws$/, `${SRC}/utils/ws`), new HtmlWebpackPlugin({ chunks: ['indexUI'], template: `${SRC}ui/index.html`, diff --git a/packages/blockchain-link/webpack/workers.web.js b/packages/blockchain-link/webpack/workers.web.js index 4197bfb4ada..ceec8caf18a 100644 --- a/packages/blockchain-link/webpack/workers.web.js +++ b/packages/blockchain-link/webpack/workers.web.js @@ -1,5 +1,3 @@ -const webpack = require('webpack'); - const { SRC, BUILD } = require('./constants'); module.exports = { @@ -48,5 +46,4 @@ module.exports = { optimization: { minimize: false, }, - plugins: [new webpack.NormalModuleReplacementPlugin(/^ws$/, `${SRC}/utils/ws`)], }; diff --git a/packages/components/src/components/skeletons/SkeletonRectangle.tsx b/packages/components/src/components/skeletons/SkeletonRectangle.tsx index b9b8d0f82a4..51b1245e6a1 100644 --- a/packages/components/src/components/skeletons/SkeletonRectangle.tsx +++ b/packages/components/src/components/skeletons/SkeletonRectangle.tsx @@ -1,6 +1,6 @@ import styled, { css } from 'styled-components'; -import { Elevation, borders, mapElevationToBackground } from '@trezor/theme'; +import { Elevation, borders, mapElevationToBackground, nextElevation } from '@trezor/theme'; import { SkeletonBaseProps } from './types'; import { getValue, shimmerEffect } from './utils'; @@ -18,7 +18,12 @@ const StyledSkeletonRectangle = styled.div< >` width: ${({ $width }) => getValue($width) ?? '80px'}; height: ${({ $height }) => getValue($height) ?? '20px'}; - background: ${({ $background, ...props }) => $background ?? mapElevationToBackground(props)}; + background: ${({ $background, ...props }) => + $background ?? + mapElevationToBackground({ + theme: props.theme, + $elevation: props.$elevation, + })}; border-radius: ${({ $borderRadius }) => getValue($borderRadius) ?? borders.radii.xs}; background-size: 200%; diff --git a/packages/connect-iframe/src/index.ts b/packages/connect-iframe/src/index.ts index 0d7b9bf0095..05ab96ef5c9 100644 --- a/packages/connect-iframe/src/index.ts +++ b/packages/connect-iframe/src/index.ts @@ -105,13 +105,13 @@ const handleMessage = async (event: MessageEvent) => { // Handle immediately, before other logic core.handleMessage({ type: POPUP.HANDSHAKE }); - const transport = core.getTransportInfo(); + const transports = core.getActiveTransports(); const settings = DataManager.getSettings(); postMessage( createPopupMessage(POPUP.HANDSHAKE, { settings: DataManager.getSettings(), - transport, + transports, }), ); _log.debug('loading current method'); @@ -134,6 +134,8 @@ const handleMessage = async (event: MessageEvent) => { isDev: process.env.NODE_ENV === 'development', }); + const transport = transports?.[0]; // TODO only the first is reported + analytics.report({ type: EventType.AppReady, payload: { diff --git a/packages/connect-mobile/src/index.ts b/packages/connect-mobile/src/index.ts index f6400f9e387..380a76b2d35 100644 --- a/packages/connect-mobile/src/index.ts +++ b/packages/connect-mobile/src/index.ts @@ -39,6 +39,11 @@ export class TrezorConnectDeeplink implements ConnectFactoryDependencies) { if (!settings.deeplinkOpen) { throw new Error('TrezorConnect native requires "deeplinkOpen" setting.'); @@ -192,6 +197,7 @@ const TrezorConnect = factory< eventEmitter: impl.eventEmitter, init: impl.init.bind(impl), call: impl.call.bind(impl), + setTransports: impl.setTransports.bind(impl), manifest: impl.manifest.bind(impl), requestLogin: impl.requestLogin.bind(impl), uiResponse: impl.uiResponse.bind(impl), diff --git a/packages/connect-popup/src/index.tsx b/packages/connect-popup/src/index.tsx index 57008e8f99b..a0f8e886bcf 100644 --- a/packages/connect-popup/src/index.tsx +++ b/packages/connect-popup/src/index.tsx @@ -306,11 +306,8 @@ const handleMessageInCoreMode = ( if (data.type === POPUP.HANDSHAKE) { handshake(data, getState().settings?.origin || ''); const core = ensureCore(); - const transport = core.getTransportInfo(); - setState({ - ...data, - transport, - }); + const transports = core.getActiveTransports(); + setState({ ...data, transports }); reactEventBus.dispatch({ type: 'state-update', payload: getState() }); return; @@ -330,7 +327,7 @@ const handleMessageInCoreMode = ( reactEventBus.dispatch({ type: 'state-update', payload: getState() }); const { settings } = getState(); - const transport = core.getTransportInfo(); + const transport = core.getActiveTransports()?.[0]; // TODO only the first is reported analytics.report({ type: EventType.AppReady, payload: { diff --git a/packages/connect-ui/src/index.tsx b/packages/connect-ui/src/index.tsx index 1989c5b9c19..8a09a550911 100644 --- a/packages/connect-ui/src/index.tsx +++ b/packages/connect-ui/src/index.tsx @@ -97,6 +97,8 @@ export const ConnectUI = ({ postMessage, clearLegacyView }: ConnectUIProps) => { }; }, [state?.settings?.origin]); + const outdated = state?.transports?.[0]?.outdated; + const [Component, Notifications] = useMemo(() => { let component: ReactNode | null; @@ -125,7 +127,7 @@ export const ConnectUI = ({ postMessage, clearLegacyView }: ConnectUIProps) => { // notifications const notifications: { [key: string]: JSX.Element } = {}; - if (state?.transport?.outdated) { + if (outdated) { notifications['bridge-outdated'] = ; } messages.forEach(message => { @@ -141,7 +143,7 @@ export const ConnectUI = ({ postMessage, clearLegacyView }: ConnectUIProps) => { }); return [component, notifications]; - }, [messages, postMessage, state?.transport?.outdated]); + }, [messages, postMessage, outdated]); useEffect(() => { if (Component) { diff --git a/packages/connect-web/src/impl/core-in-iframe.ts b/packages/connect-web/src/impl/core-in-iframe.ts index a4e19acbd26..79288ddd740 100644 --- a/packages/connect-web/src/impl/core-in-iframe.ts +++ b/packages/connect-web/src/impl/core-in-iframe.ts @@ -212,6 +212,11 @@ export class CoreInIframe implements ConnectFactoryDependencies( eventEmitter: impl.eventEmitter, init: impl.init.bind(impl), call: impl.call.bind(impl), + setTransports: impl.setTransports.bind(impl), manifest: impl.manifest.bind(impl), requestLogin: impl.requestLogin.bind(impl), uiResponse: impl.uiResponse.bind(impl), diff --git a/packages/connect-web/src/module/index.ts b/packages/connect-web/src/module/index.ts index 22778d4b429..d93cdc49851 100644 --- a/packages/connect-web/src/module/index.ts +++ b/packages/connect-web/src/module/index.ts @@ -11,9 +11,9 @@ import { TRANSPORT, TRANSPORT_EVENT, } from '@trezor/connect/src/exports'; -import { getInstallerPackage } from '@trezor/connect-common'; -import { suggestBridgeInstaller } from '@trezor/connect/src/data/transportInfo'; -import { suggestUdevInstaller } from '@trezor/connect/src/data/udevInfo'; +// import { getInstallerPackage } from '@trezor/connect-common'; +// import { suggestBridgeInstaller } from '@trezor/connect/src/data/transportInfo'; +// import { suggestUdevInstaller } from '@trezor/connect/src/data/udevInfo'; interface ConnectWebDynamicImplementation extends ConnectFactoryDependencies { @@ -30,9 +30,9 @@ const impl = new TrezorConnectDynamic< type: 'core-in-module', impl: new CoreInModule((message: CoreEventMessage) => { if (message.event === TRANSPORT_EVENT) { - const platform = getInstallerPackage(); - message.payload.bridge = suggestBridgeInstaller(platform); - message.payload.udev = suggestUdevInstaller(platform); + // const platform = getInstallerPackage(); + // message.payload.bridge = suggestBridgeInstaller(platform); + // message.payload.udev = suggestUdevInstaller(platform); } return message; @@ -62,6 +62,7 @@ const TrezorConnect = factory( eventEmitter: impl.eventEmitter, init: impl.init.bind(impl), call: impl.call.bind(impl), + setTransports: impl.setTransports.bind(impl), manifest: impl.manifest.bind(impl), requestLogin: impl.requestLogin.bind(impl), uiResponse: impl.uiResponse.bind(impl), diff --git a/packages/connect-webextension/src/index.ts b/packages/connect-webextension/src/index.ts index 9258cb261b9..34fde0c8193 100644 --- a/packages/connect-webextension/src/index.ts +++ b/packages/connect-webextension/src/index.ts @@ -57,6 +57,7 @@ const TrezorConnect = factory({ eventEmitter: methods.eventEmitter, init: methods.init.bind(methods), call: methods.call.bind(methods), + setTransports: methods.setTransports.bind(methods), manifest: methods.manifest.bind(methods), requestLogin: methods.requestLogin.bind(methods), uiResponse: methods.uiResponse.bind(methods), diff --git a/packages/connect-webextension/src/proxy/index.ts b/packages/connect-webextension/src/proxy/index.ts index 209a4b2f501..46b2e2fe33a 100644 --- a/packages/connect-webextension/src/proxy/index.ts +++ b/packages/connect-webextension/src/proxy/index.ts @@ -80,6 +80,11 @@ const init = (settings: Partial = {}): Promise => { ); }; +const setTransports = () => { + // TODO: implement + throw new Error('Unsupported right now'); +}; + const call: CallMethod = async (params: any) => { try { const response = await _channel.postMessage({ @@ -113,6 +118,7 @@ const TrezorConnect = factory({ manifest, init, call, + setTransports, requestLogin, uiResponse, cancel, diff --git a/packages/connect/e2e/karma.config.js b/packages/connect/e2e/karma.config.js index 3836c2a0b1e..8f7f6140bec 100644 --- a/packages/connect/e2e/karma.config.js +++ b/packages/connect/e2e/karma.config.js @@ -106,12 +106,6 @@ module.exports = config => { path.join(__dirname, '../../connect-web/build/trezor-connect.js'), ), - // replace ws module used in ./tests/websocket-client.js - new webpack.NormalModuleReplacementPlugin( - /ws$/, - '@trezor/blockchain-link/src/utils/ws', - ), - new webpack.DefinePlugin({ // set custom connect endpoint to build directory 'process.env.TREZOR_CONNECT_SRC': JSON.stringify( diff --git a/packages/connect/e2e/tests/device/methods.test.ts b/packages/connect/e2e/tests/device/methods.test.ts index 6cb2504aa78..d6db4623586 100644 --- a/packages/connect/e2e/tests/device/methods.test.ts +++ b/packages/connect/e2e/tests/device/methods.test.ts @@ -71,73 +71,75 @@ describe(`TrezorConnect methods`, () => { } }); - getFixtures().forEach((testCase: TestCase) => { - describe(`TrezorConnect.${testCase.method}`, () => { - beforeAll(async () => { - TrezorConnect.dispose(); - - try { - if (!controller) { - controller = getController(); - // controller.on('error', () => { - // controller = undefined; - // }); - } - - await setup(controller, testCase.setup); - - await initTrezorConnect(controller); - } catch (error) { - // eslint-disable-next-line no-console - console.log('Controller WS init error', error); - } - }, 40000); - - afterEach(() => { - TrezorConnect.cancel(); - }); - - testCase.tests.forEach(t => { - // check if test should be skipped on current configuration - conditionalTest( - t.skip, - t.description, - async () => { - // print current test case, `jest` default reporter doesn't log this. see https://github.com/facebook/jest/issues/4471 - if (typeof jest !== 'undefined' && process.stderr) { - process.stderr.write(`\n${testCase.method}: ${t.description}\n`); - } - + getFixtures() + .slice(0, 1) + .forEach((testCase: TestCase) => { + describe(`TrezorConnect.${testCase.method}`, () => { + beforeAll(async () => { + await TrezorConnect.dispose(); + + try { if (!controller) { - throw new Error('Controller not found'); + controller = getController(); + // controller.on('error', () => { + // controller = undefined; + // }); } - // single test may require a different setup - await setup(controller, t.setup || testCase.setup); - - // @ts-expect-error, string + params union - const result = await TrezorConnect[testCase.method](t.params); - let expected = t.result - ? { success: true, payload: t.result } - : { success: false }; - - // find legacy result - const { legacyResults } = t; - if (legacyResults) { - legacyResults.forEach(r => { - if (skipTest(r.rules)) { - expected = r.payload - ? { success: true, payload: r.payload } - : { success: false }; - } - }); - } + await setup(controller, testCase.setup); - expect(result).toMatchObject(expected); - }, - t.customTimeout || 20000, - ); + await initTrezorConnect(controller); + } catch (error) { + // eslint-disable-next-line no-console + console.log('Controller WS init error', error); + } + }, 40000); + + afterEach(() => { + TrezorConnect.cancel(); + }); + + testCase.tests.slice(0, 1).forEach(t => { + // check if test should be skipped on current configuration + conditionalTest( + t.skip, + t.description, + async () => { + // print current test case, `jest` default reporter doesn't log this. see https://github.com/facebook/jest/issues/4471 + if (typeof jest !== 'undefined' && process.stderr) { + process.stderr.write(`\n${testCase.method}: ${t.description}\n`); + } + + if (!controller) { + throw new Error('Controller not found'); + } + + // single test may require a different setup + await setup(controller, t.setup || testCase.setup); + + // @ts-expect-error, string + params union + const result = await TrezorConnect[testCase.method](t.params); + let expected = t.result + ? { success: true, payload: t.result } + : { success: false }; + + // find legacy result + const { legacyResults } = t; + if (legacyResults) { + legacyResults.forEach(r => { + if (skipTest(r.rules)) { + expected = r.payload + ? { success: true, payload: r.payload } + : { success: false }; + } + }); + } + + expect(result).toMatchObject(expected); + }, + t.customTimeout || 20000, + ); + }); }); }); - }); }); diff --git a/packages/connect/src/api/eraseBonds.ts b/packages/connect/src/api/eraseBonds.ts new file mode 100644 index 00000000000..cbe6f8185cf --- /dev/null +++ b/packages/connect/src/api/eraseBonds.ts @@ -0,0 +1,26 @@ +// origin: https://github.com/trezor/connect/blob/develop/src/js/core/methods/ApplyFlags.js + +import { AbstractMethod } from '../core/AbstractMethod'; +import { UI } from '../events'; +import { PROTO } from '../constants'; +import { Assert } from '@trezor/schema-utils'; + +export default class EraseBonds extends AbstractMethod<'eraseBonds', PROTO.ApplyFlags> { + init() { + this.allowDeviceMode = [UI.INITIALIZE, UI.SEEDLESS]; + this.requiredPermissions = ['management']; + this.useDeviceState = false; + this.skipFinalReload = true; + + const { payload } = this; + + Assert(PROTO.EraseBonds, payload); + } + + async run() { + const cmd = this.device.getCommands(); + const response = await cmd.typedCall('EraseBonds', 'Success', {}); + + return response.message; + } +} diff --git a/packages/connect/src/api/index.ts b/packages/connect/src/api/index.ts index eeb7d84f5bc..af7c77c42fa 100644 --- a/packages/connect/src/api/index.ts +++ b/packages/connect/src/api/index.ts @@ -26,6 +26,7 @@ export { default as cipherKeyValue } from './cipherKeyValue'; export { default as composeTransaction } from './composeTransaction'; // export { default as disableWebUSB } from './disableWebUSB'; // export { default as dispose } from './dispose'; +export { default as eraseBonds } from './eraseBonds'; export { default as getAccountDescriptor } from './getAccountDescriptor'; export { default as getAccountInfo } from './getAccountInfo'; export { default as getAddress } from './getAddress'; diff --git a/packages/connect/src/core/__tests__/Core.test.ts b/packages/connect/src/core/__tests__/Core.test.ts index bcd9e4dbcd8..6e2c44adf6c 100644 --- a/packages/connect/src/core/__tests__/Core.test.ts +++ b/packages/connect/src/core/__tests__/Core.test.ts @@ -57,7 +57,7 @@ describe('Core', () => { expect(eventsSpy).toHaveBeenCalledTimes(0); await new Promise(resolve => setTimeout(resolve, 1)); // device + transport events emitted in next tick - expect(eventsSpy).toHaveBeenCalledTimes(4); + expect(eventsSpy).toHaveBeenCalledTimes(5); coreManager.dispose(); }); diff --git a/packages/connect/src/core/index.ts b/packages/connect/src/core/index.ts index 62c96354a6d..59c21288766 100644 --- a/packages/connect/src/core/index.ts +++ b/packages/connect/src/core/index.ts @@ -73,7 +73,7 @@ const initDevice = async (context: CoreContext, methodCallDevice?: DeviceIdentit assertDeviceListConnected(deviceList); - const isWebUsb = deviceList.transportType() === 'WebUsbTransport'; + const isWebUsb = deviceList.getActiveTransports().some(t => t.type === 'WebUsbTransport'); let device: Device | typeof undefined; let showDeviceSelection = isWebUsb; const isUsingPopup = DataManager.getSettings('popup'); @@ -545,11 +545,11 @@ const onCallDevice = async ( const { deviceList, callMethods, getOverridePromise, setOverridePromise, sendCoreMessage } = context; const responseID = message.id; - const { origin, env, useCoreInPopup } = DataManager.getSettings(); + const { origin, env, useCoreInPopup, transports } = DataManager.getSettings(); if (!deviceList.isConnected() && !deviceList.pendingConnection()) { // transport is missing try to initialize it once again - deviceList.init(); + deviceList.init({ transports }); } await deviceList.pendingConnection(); @@ -953,7 +953,7 @@ const handleDeviceSelectionChanges = (context: CoreContext, interruptDevice?: De const promiseExists = uiPromises.exists(UI.RECEIVE_DEVICE); if (promiseExists && deviceList.isConnected()) { const onlyDevice = deviceList.getOnlyDevice(); - const isWebUsb = deviceList.transportType() === 'WebUsbTransport'; + const isWebUsb = deviceList.getActiveTransports().some(t => t.type === 'WebUsbTransport'); if (onlyDevice && !isWebUsb) { // there is only one device. use it @@ -990,31 +990,35 @@ const initDeviceList = (context: CoreContext) => { deviceList.on(DEVICE.CONNECT, device => { handleDeviceSelectionChanges(context); - sendCoreMessage(createDeviceMessage(DEVICE.CONNECT, device)); + sendCoreMessage(createDeviceMessage(DEVICE.CONNECT, device.toMessageObject())); }); deviceList.on(DEVICE.CONNECT_UNACQUIRED, device => { handleDeviceSelectionChanges(context); - sendCoreMessage(createDeviceMessage(DEVICE.CONNECT_UNACQUIRED, device)); + sendCoreMessage(createDeviceMessage(DEVICE.CONNECT_UNACQUIRED, device.toMessageObject())); }); deviceList.on(DEVICE.DISCONNECT, device => { handleDeviceSelectionChanges(context); - sendCoreMessage(createDeviceMessage(DEVICE.DISCONNECT, device)); + sendCoreMessage(createDeviceMessage(DEVICE.DISCONNECT, device.toMessageObject())); }); deviceList.on(DEVICE.CHANGED, device => { - sendCoreMessage(createDeviceMessage(DEVICE.CHANGED, device)); + sendCoreMessage(createDeviceMessage(DEVICE.CHANGED, device.toMessageObject())); }); - deviceList.on(TRANSPORT.START, transportType => - sendCoreMessage(createTransportMessage(TRANSPORT.START, transportType)), + deviceList.on(TRANSPORT.START, transports => + sendCoreMessage(createTransportMessage(TRANSPORT.START, transports[0])), ); deviceList.on(TRANSPORT.ERROR, error => { _log.warn('TRANSPORT.ERROR', error); sendCoreMessage(createTransportMessage(TRANSPORT.ERROR, { error })); }); + + deviceList.on(TRANSPORT.CHANGED, transport => + sendCoreMessage(createTransportMessage(TRANSPORT.CHANGED, transport)), + ); }; /** @@ -1098,8 +1102,20 @@ export class Core extends EventEmitter { ); break; - case TRANSPORT.DISABLE_WEBUSB: - disableWebUSBTransport(this.getCoreContext()); + case TRANSPORT.DISABLE_WEBUSB: { + const settings = DataManager.getSettings(); + const transports = settings.transports?.filter(t => t !== 'WebUsbTransport'); + if (transports && !transports.includes('BridgeTransport')) { + transports.unshift('BridgeTransport'); + } + settings.transports = transports; + + resetTransports(this.getCoreContext()); + break; + } + case TRANSPORT.SET_TRANSPORTS: + DataManager.getSettings().transports = message.payload.transports; + resetTransports(this.getCoreContext()); break; case TRANSPORT.REQUEST_DEVICE: @@ -1115,7 +1131,7 @@ export class Core extends EventEmitter { case TRANSPORT.GET_INFO: this.sendCoreMessage( - createResponseMessage(message.id, true, this.getTransportInfo()), + createResponseMessage(message.id, true, this.getActiveTransports()), ); break; @@ -1181,9 +1197,10 @@ export class Core extends EventEmitter { return await this.methodSynchronize(() => this.callMethods[0]); } - getTransportInfo(): TransportInfo | undefined { + getActiveTransports(): TransportInfo[] | undefined { if (this.deviceList.isConnected()) { - return this.deviceList.getTransportInfo(); + // TODO now returns only the first active transport + return this.deviceList.getActiveTransports(); } } @@ -1242,16 +1259,13 @@ export class Core extends EventEmitter { DataManager.getSettings(); try { - this.deviceList.setTransports(transports); + this.deviceList.init({ transports, pendingTransportEvent, transportReconnect }); } catch (error) { - _log.error('setTransports', error); this.sendCoreMessage(createTransportMessage(TRANSPORT.ERROR, { error })); throttlePromise.reject(error); throw error; } - this.deviceList.init({ pendingTransportEvent, transportReconnect }); - // in auto core mode, we have to wait to check if transport is available if (!transportReconnect || coreMode === 'auto') { await this.deviceList.pendingConnection(); @@ -1264,29 +1278,11 @@ export class Core extends EventEmitter { } } -const disableWebUSBTransport = async ({ deviceList, sendCoreMessage }: CoreContext) => { - if (!deviceList.isConnected()) return; - if (deviceList.transportType() !== 'WebUsbTransport') return; - // override settings +const resetTransports = async ({ deviceList, sendCoreMessage }: CoreContext) => { const { transports, pendingTransportEvent, transportReconnect } = DataManager.getSettings(); - if (transports) { - const transportStr = transports?.filter(transport => typeof transport !== 'object'); - if (transportStr.includes('WebUsbTransport')) { - transports.splice(transports.indexOf('WebUsbTransport'), 1); - } - if (!transportStr.includes('BridgeTransport')) { - transports!.unshift('BridgeTransport'); - } - } - try { - // clean previous device list - deviceList.cleanup(); - // and init with new settings, without webusb - deviceList.setTransports(transports); - // TODO possible issue with new init not replacing the old one??? - await deviceList.init({ pendingTransportEvent, transportReconnect }); + await deviceList.init({ transports, pendingTransportEvent, transportReconnect }); } catch (error) { // do nothing sendCoreMessage(createTransportMessage(TRANSPORT.ERROR, { error })); diff --git a/packages/connect/src/device/Device.ts b/packages/connect/src/device/Device.ts index 16662fe5fbc..1a88de5fc3b 100644 --- a/packages/connect/src/device/Device.ts +++ b/packages/connect/src/device/Device.ts @@ -130,9 +130,10 @@ type DeviceParams = { export class Device extends TypedEmitter { public readonly transport: Transport; public readonly protocol: TransportProtocol; - private readonly transportPath; + public readonly transportPath; private readonly transportSessionOwner; private readonly transportDescriptorType; + private readonly bluetoothProps; private session; private lastAcquiredHere; @@ -221,6 +222,7 @@ export class Device extends TypedEmitter { this.transportPath = descriptor.path; this.transportSessionOwner = descriptor.sessionOwner; this.transportDescriptorType = descriptor.type; + this.bluetoothProps = descriptor.uuid ? { uuid: descriptor.uuid } : undefined; this.session = descriptor.session; this.lastAcquiredHere = false; @@ -1183,6 +1185,7 @@ export class Device extends TypedEmitter { label: 'Unacquired device', name: this.name, transportSessionOwner: this.transportSessionOwner, + bluetoothProps: this.bluetoothProps, }; } const defaultLabel = 'My Trezor'; @@ -1208,6 +1211,7 @@ export class Device extends TypedEmitter { unavailableCapabilities: this.unavailableCapabilities, availableTranslations: this.availableTranslations, authenticityChecks: this.authenticityChecks, + bluetoothProps: this.bluetoothProps, }; } } diff --git a/packages/connect/src/device/DeviceList.ts b/packages/connect/src/device/DeviceList.ts index 81e8e34bd7a..824b63ca6a5 100644 --- a/packages/connect/src/device/DeviceList.ts +++ b/packages/connect/src/device/DeviceList.ts @@ -1,6 +1,13 @@ // original file https://github.com/trezor/connect/blob/develop/src/js/device/DeviceList.js -import { TypedEmitter, createDeferred, getSynchronize } from '@trezor/utils'; +import { + TypedEmitter, + arrayDistinct, + arrayPartition, + createDeferred, + getSynchronize, + isNotUndefined, +} from '@trezor/utils'; import { BridgeTransport, WebUsbTransport, @@ -11,24 +18,17 @@ import { isTransportInstance, } from '@trezor/transport'; import { Descriptor, PathPublic } from '@trezor/transport/src/types'; +import type { TransportApiType } from '@trezor/transport/src/transports/abstract'; import { ERRORS } from '../constants'; -import { DEVICE, TransportInfo } from '../events'; +import { DEVICE, TransportInfo, TransportTypeState } from '../events'; import { Device } from './Device'; -import { - ConnectSettings, - DeviceUniquePath, - Device as DeviceTyped, - StaticSessionId, -} from '../types'; +import { ConnectSettings, DeviceUniquePath, StaticSessionId } from '../types'; import { getBridgeInfo } from '../data/transportInfo'; import { initLog } from '../utils/debug'; -import { resolveAfter } from '../utils/promiseUtils'; +import { abortablePromise } from '../utils/promiseUtils'; import { typedObjectKeys } from '../types/utils'; -// custom log -const _log = initLog('DeviceList'); - const createAuthPenaltyManager = (priority: number) => { const penalizedDevices: { [deviceID: string]: number } = {}; @@ -57,19 +57,55 @@ const createAuthPenaltyManager = (priority: number) => { return { get, add, remove, clear }; }; +type DeviceTransport = Pick; + +const createDeviceCollection = () => { + let devices: Device[] = []; + + const isEqual = (a: DeviceTransport) => (b: DeviceTransport) => + a.transport === b.transport && a.transportPath === b.transportPath; + + const get = (transportPath: PathPublic, transport: Transport) => + devices.find(isEqual({ transport, transportPath })); + + const all = (): readonly Device[] => devices; + + const add = (device: Device) => { + const index = devices.findIndex(isEqual(device)); + if (index >= 0) devices[index] = device; + else devices.push(device); + }; + + const remove = (transportPath: PathPublic, transport: Transport) => { + const index = devices.findIndex(isEqual({ transport, transportPath })); + const [removed] = index >= 0 ? devices.splice(index, 1) : [undefined]; + + return removed; + }; + + const clear = (transport?: Transport) => { + let removed: Device[]; + [removed, devices] = arrayPartition(devices, d => !transport || d.transport === transport); + + return removed; + }; + + return { get, all, add, remove, clear }; +}; + interface DeviceListEvents { - [TRANSPORT.START]: TransportInfo; + [TRANSPORT.START]: TransportInfo[]; [TRANSPORT.ERROR]: string; - [DEVICE.CONNECT]: DeviceTyped; - [DEVICE.CONNECT_UNACQUIRED]: DeviceTyped; - [DEVICE.DISCONNECT]: DeviceTyped; - [DEVICE.CHANGED]: DeviceTyped; + [TRANSPORT.CHANGED]: TransportTypeState; + [DEVICE.CONNECT]: Device; + [DEVICE.CONNECT_UNACQUIRED]: Device; + [DEVICE.DISCONNECT]: Device; + [DEVICE.CHANGED]: Device; } export interface IDeviceList { isConnected(): this is DeviceList; pendingConnection(): Promise | undefined; - setTransports: DeviceList['setTransports']; addAuthPenalty: DeviceList['addAuthPenalty']; removeAuthPenalty: DeviceList['removeAuthPenalty']; on: DeviceList['on']; @@ -81,7 +117,9 @@ export interface IDeviceList { export const assertDeviceListConnected: ( deviceList: IDeviceList, ) => asserts deviceList is DeviceList = deviceList => { - if (!deviceList.isConnected()) throw ERRORS.TypedError('Transport_Missing'); + if (!deviceList.isConnected()) { + throw ERRORS.TypedError('Transport_Missing'); + } }; type ConstructorParams = Pick< @@ -90,33 +128,77 @@ type ConstructorParams = Pick< > & { messages: Record; }; -type InitParams = Pick; +type InitParams = Pick< + ConnectSettings, + 'transports' | 'pendingTransportEvent' | 'transportReconnect' +>; + +type ApiTypeMap = Partial>; export class DeviceList extends TypedEmitter implements IDeviceList { - // @ts-expect-error has no initializer - private transport: Transport; + private readonly transport: ApiTypeMap = {}; // array of transport that might be used in this environment - private transports: Transport[]; + private transports: Transport[] = []; - private devices: Record = {}; + private readonly devices = createDeviceCollection(); private deviceCounter = Date.now(); private readonly handshakeLock; private readonly authPenaltyManager; - private initPromise?: Promise; - - private rejectPending?: (e: Error) => void; - private transportCommonArgs; isConnected(): this is DeviceList { - return !!this.transport; + return !!Object.keys(this.transport).length; } pendingConnection() { - return this.initPromise; + const pending = Object.values(this.locks) + .map(({ promise }) => promise) + .filter(isNotUndefined); + + if (pending.length) return Promise.all(pending).then(() => {}); + } + + getActiveTransports() { + return Object.values(this.transport).map(transport => ({ + type: transport.name, + version: transport.version, + outdated: transport.isOutdated, + })); + } + + private readonly locks: ApiTypeMap<{ + promise?: Promise; + abort?: AbortController; + abortMessage?: string; + sequence: number; + }> = {}; + + private async transportLock( + apiType: TransportApiType, + abortMessage: string, + action: (signal: AbortSignal) => Promise, + ): Promise { + const lock = this.locks[apiType] ?? (this.locks[apiType] = { sequence: 0 }); + lock.abortMessage = abortMessage; + const sequence = ++lock.sequence; + + while (lock.promise) { + lock.abort?.abort(new Error(abortMessage)); + await lock.promise.catch(() => {}); + } + + if (sequence !== lock.sequence) return Promise.reject(new Error(lock.abortMessage)); + + lock.abort = new AbortController(); + lock.promise = action(lock.abort.signal).finally(() => { + delete lock.abort; + delete lock.promise; + }); + + return lock.promise as Promise; } constructor({ @@ -138,19 +220,21 @@ export class DeviceList extends TypedEmitter implements IDevic sessionsBackgroundUrl: _sessionsBackgroundUrl, id: manifest?.appUrl || 'unknown app', }; + } - this.transports = [ - new BridgeTransport({ - latestVersion: getBridgeInfo().version.join('.'), - ...this.transportCommonArgs, - }), - ]; + private tryGetTransport(name: string) { + return this.transports.find(t => t.name === name); } - private createTransport(transportType: NonNullable[number]) { + private getOrCreateTransport( + transportType: NonNullable[number], + ) { const { transportCommonArgs } = this; if (typeof transportType === 'string') { + const existing = this.tryGetTransport(transportType); + if (existing) return existing; + switch (transportType) { case 'WebUsbTransport': return new WebUsbTransport(transportCommonArgs); @@ -167,9 +251,13 @@ export class DeviceList extends TypedEmitter implements IDevic } else if (typeof transportType === 'function' && 'prototype' in transportType) { const transportInstance = new transportType(transportCommonArgs); if (isTransportInstance(transportInstance)) { - return transportInstance; + return this.tryGetTransport(transportInstance.name) ?? transportInstance; } } else if (isTransportInstance(transportType)) { + if (this.tryGetTransport(transportType.name)) { + return transportType; + } + // custom Transport might be initialized without messages, update them if so if (!transportType.getMessage()) { transportType.updateMessages(transportCommonArgs.messages); @@ -185,105 +273,166 @@ export class DeviceList extends TypedEmitter implements IDevic ); } - setTransports(transports: ConnectSettings['transports']) { - // we fill in `transports` with a reasonable fallback in src/index. - // since web index is released into npm, we can not rely - // on that that transports will be always set here. We need to provide a 'fallback of the last resort' + private createTransports(transports: ConnectSettings['transports']) { + // BridgeTransport is the ultimate fallback const transportTypes = transports?.length ? transports : ['BridgeTransport' as const]; - this.transports = transportTypes.map(this.createTransport.bind(this)); + return transportTypes.map(this.getOrCreateTransport.bind(this)); } private onDeviceConnected(descriptor: Descriptor, transport: Transport) { - const { path } = descriptor; const id = (this.deviceCounter++).toString(16).slice(-8); const device = new Device({ id: DeviceUniquePath(id), transport, descriptor, - listener: lifecycle => this.emit(lifecycle, device.toMessageObject()), + listener: lifecycle => this.emit(lifecycle, device), }); - this.devices[path] = device; + this.devices.add(device); const penalty = this.authPenaltyManager.get(); this.handshakeLock(async () => { - if (this.devices[path]) { + if (this.devices.get(descriptor.path, transport)) { // device wasn't removed while waiting for lock await device.handshake(penalty); } }); } - private onDeviceDisconnected(descriptor: Descriptor) { - const { path } = descriptor; - const device = this.devices[path]; - if (device) { - device.disconnect(); - delete this.devices[path]; - } + private onDeviceDisconnected(descriptor: Descriptor, transport: Transport) { + const device = this.devices.remove(descriptor.path, transport); + device?.disconnect(); } - private onDeviceSessionChanged(descriptor: Descriptor) { - const device = this.devices[descriptor.path]; - if (device) { - device.updateDescriptor(descriptor); - } + private onDeviceSessionChanged(descriptor: Descriptor, transport: Transport) { + const device = this.devices.get(descriptor.path, transport); + device?.updateDescriptor(descriptor); } - private onDeviceRequestRelease(descriptor: Descriptor) { - this.devices[descriptor.path]?.usedElsewhere(); + private onDeviceRequestRelease(descriptor: Descriptor, transport: Transport) { + const device = this.devices.get(descriptor.path, transport); + device?.usedElsewhere(); } - /** - * Init @trezor/transport and do something with its results - */ - init(initParams: InitParams = {}) { - // TODO is it ok to return first init promise in case of second call? - if (!this.initPromise) { - _log.debug('Initializing transports'); - this.initPromise = this.createInitPromise(initParams); - } + async init(initParams: InitParams = {}) { + // throws when unknown transport is requested, in that case nothing is changed + this.transports = this.createTransports(initParams.transports); + + const promises = this.transports + .map(t => t.apiType) + .concat(typedObjectKeys(this.transport)) + .concat(typedObjectKeys(this.locks)) + .filter(arrayDistinct) + .map(apiType => + this.transportLock(apiType, 'New init', signal => + this.createInitPromise(apiType, initParams, signal), + ), + ); + + await Promise.all(promises); + } - return this.initPromise; + private emitTransportChange(type: TransportApiType, status: 'running', name: string): void; + private emitTransportChange(type: TransportApiType, status: 'error', error: string): void; + private emitTransportChange(type: TransportApiType, status: 'stopped'): void; + private emitTransportChange(type: TransportApiType, status: string, nameOrError?: string) { + this.emit(TRANSPORT.CHANGED, { + type, + status, + name: status === 'running' ? nameOrError : undefined, + error: status === 'error' ? nameOrError : undefined, + } as TransportTypeState); } - private createInitPromise(initParams: InitParams) { - return this.selectTransport(this.transports) - .then(transport => this.initializeTransport(transport, initParams)) - .then(transport => { - this.transport = transport; - this.emit(TRANSPORT.START, this.getTransportInfo()); - this.initPromise = undefined; - }) - .catch(error => { - this.cleanup(); - this.emit(TRANSPORT.ERROR, error); - this.initPromise = initParams.transportReconnect - ? this.createReconnectPromise(initParams) - : undefined; - }); + private noActiveTransport() { + return !Object.keys(this.transport).length; } - private createReconnectPromise(initParams: InitParams) { - const { promise, reject } = resolveAfter(1000, initParams); - this.rejectPending = reject; + private async createInitPromise( + apiType: TransportApiType, + initParams: InitParams, + abortSignal: AbortSignal, + ) { + try { + const transports = this.transports.filter(t => t.apiType === apiType); + const transport = transports.length + ? await this.selectTransport(transports, abortSignal) + : undefined; + const oldTransport = this.transport[apiType]; + if (oldTransport !== transport) { + if (oldTransport) { + delete this.transport[apiType]; + await this.stopTransport(oldTransport); + this.emitTransportChange(apiType, 'stopped'); + if (!transport && this.noActiveTransport()) + this.emit(TRANSPORT.ERROR, 'No active transports'); + } + + if (transport) { + try { + await this.initializeTransport(transport, initParams, abortSignal); + } catch (error) { + await this.stopTransport(transport); + throw error; + } + + transport.on(TRANSPORT.ERROR, error => { + this.emitTransportChange(apiType, 'error', error); + this.transportLock(apiType, 'Transport error', async signal => { + delete this.transport[apiType]; + if (this.noActiveTransport()) + this.emit(TRANSPORT.ERROR, 'No active transports'); + await this.stopTransport(transport); + if (initParams.transportReconnect) { + await this.createReconnectDelay(signal); + await this.createInitPromise(apiType, initParams, signal); + } + }); + }); - return promise - .then(this.createInitPromise.bind(this)) - .catch(() => {}) - .finally(() => { - this.rejectPending = undefined; - }); + const noActiveTransport = this.noActiveTransport(); + this.transport[apiType] = transport; + this.emitTransportChange(apiType, 'running', transport.name); + if (!oldTransport && noActiveTransport) + this.emit(TRANSPORT.START, this.getActiveTransports()); + } + } + } catch (error) { + this.emitTransportChange(apiType, 'error', error?.message); + if (this.noActiveTransport()) this.emit(TRANSPORT.ERROR, 'No active transports'); + if (initParams.transportReconnect && !abortSignal.aborted) { + this.transportLock(apiType, 'Reconnecting', async signal => { + await this.createReconnectDelay(signal); + await this.createInitPromise(apiType, initParams, signal); + }); + } + } } - private async selectTransport([transport, ...rest]: Transport[]): Promise { - const result = await transport.init(); + private createReconnectDelay(signal: AbortSignal) { + const { promise, resolve } = abortablePromise(signal); + const timeout = setTimeout(resolve, 1000); + + return promise.finally(() => clearTimeout(timeout)); + } + + private async selectTransport( + [transport, ...rest]: Transport[], + signal: AbortSignal, + ): Promise { + if (signal.aborted) throw new Error(signal.reason); + if (transport === this.transport) return transport; + const result = await transport.init({ signal }); if (result.success) return transport; - else if (rest.length) return this.selectTransport(rest); + else if (rest.length) return this.selectTransport(rest, signal); else throw new Error(result.error); } - private async initializeTransport(transport: Transport, initParams: InitParams) { + private async initializeTransport( + transport: Transport, + initParams: InitParams, + signal: AbortSignal, + ) { /** * listen to change of descriptors reported by @trezor/transport * we can say that this part lets connect know about @@ -293,22 +442,17 @@ export class DeviceList extends TypedEmitter implements IDevic * where transport.acquire, transport.release is called */ transport.on(TRANSPORT.DEVICE_CONNECTED, d => this.onDeviceConnected(d, transport)); - transport.on(TRANSPORT.DEVICE_DISCONNECTED, this.onDeviceDisconnected.bind(this)); - transport.on(TRANSPORT.DEVICE_SESSION_CHANGED, this.onDeviceSessionChanged.bind(this)); - transport.on(TRANSPORT.DEVICE_REQUEST_RELEASE, this.onDeviceRequestRelease.bind(this)); - - // just like transport emits updates, it may also start producing errors, for example bridge process crashes. - transport.on(TRANSPORT.ERROR, error => { - this.cleanup(); - this.emit(TRANSPORT.ERROR, error); - if (initParams.transportReconnect) { - this.initPromise = this.createReconnectPromise(initParams); - } - }); + transport.on(TRANSPORT.DEVICE_DISCONNECTED, d => this.onDeviceDisconnected(d, transport)); + transport.on(TRANSPORT.DEVICE_SESSION_CHANGED, d => + this.onDeviceSessionChanged(d, transport), + ); + transport.on(TRANSPORT.DEVICE_REQUEST_RELEASE, d => + this.onDeviceRequestRelease(d, transport), + ); // enumerating for the first time. we intentionally postpone emitting TRANSPORT_START // event until we read descriptors for the first time - const enumerateResult = await transport.enumerate(); + const enumerateResult = await transport.enumerate({ signal }); if (!enumerateResult.success) { throw new Error(enumerateResult.error); @@ -318,126 +462,141 @@ export class DeviceList extends TypedEmitter implements IDevic const waitForDevicesPromise = initParams.pendingTransportEvent && descriptors.length - ? this.waitForDevices(descriptors.length, 10000) + ? this.waitForDevices(transport, descriptors, signal) : Promise.resolve(); transport.handleDescriptorsChange(descriptors); transport.listen(); await waitForDevicesPromise; - - return transport; } - private waitForDevices(deviceCount: number, autoResolveMs: number) { - const { promise, resolve, reject } = createDeferred(); - let transportStartPending = deviceCount; + /** + * Returned promise: + * - resolves when all the devices visible from given transport were acquired (or at least tried to) + * - resolves after 10 secs (in order not to get stuck waiting for devices) + * - rejects when aborted (e.g. because of DeviceList reinit) + * - rejects when given transport emits an error + * + * Old note: when TRANSPORT.START_PENDING is emitted, we already know that transport is available + * but we wait with emitting TRANSPORT.START event to the implementator until we read from devices + * in case something wrong happens and we never finish reading from devices for whatever reason + * implementator could get stuck waiting from TRANSPORT.START event forever. To avoid this, + * we emit TRANSPORT.START event after autoResolveTransportEventTimeout + */ + private waitForDevices(transport: Transport, descriptors: Descriptor[], signal: AbortSignal) { + const { promise, reject, resolve } = createDeferred(); + + const onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort); - /** - * when TRANSPORT.START_PENDING is emitted, we already know that transport is available - * but we wait with emitting TRANSPORT.START event to the implementator until we read from devices - * in case something wrong happens and we never finish reading from devices for whatever reason - * implementator could get stuck waiting from TRANSPORT.START event forever. To avoid this, - * we emit TRANSPORT.START event after autoResolveTransportEventTimeout - */ - const autoResolveTransportEventTimeout = setTimeout(resolve, autoResolveMs); - this.rejectPending = reject; + const onError = (error: string) => reject(new Error(error)); + transport.once(TRANSPORT.ERROR, onError); - const onDeviceConnect = () => { - transportStartPending--; - if (transportStartPending === 0) { - resolve(); - } + const autoResolveTransportEventTimeout = setTimeout(resolve, 10000); + + const remaining = descriptors.slice(); + + const onDeviceEvent = (device: Device) => { + const index = remaining.findIndex( + d => d.path === device.transportPath && transport === device.transport, + ); + if (index >= 0) remaining.splice(index, 1); + if (!remaining.length) resolve(); }; // listen for self emitted events and resolve pending transport event if needed - this.on(DEVICE.CONNECT, onDeviceConnect); - this.on(DEVICE.CONNECT_UNACQUIRED, onDeviceConnect); + this.on(DEVICE.CONNECT, onDeviceEvent); + this.on(DEVICE.CONNECT_UNACQUIRED, onDeviceEvent); + this.on(DEVICE.DISCONNECT, onDeviceEvent); return promise.finally(() => { - this.rejectPending = undefined; + transport.off(TRANSPORT.ERROR, onError); + signal.removeEventListener('abort', onAbort); clearTimeout(autoResolveTransportEventTimeout); - this.off(DEVICE.CONNECT, onDeviceConnect); - this.off(DEVICE.CONNECT_UNACQUIRED, onDeviceConnect); + this.off(DEVICE.CONNECT, onDeviceEvent); + this.off(DEVICE.CONNECT_UNACQUIRED, onDeviceEvent); + this.off(DEVICE.DISCONNECT, onDeviceEvent); }); } getDeviceCount() { - return Object.keys(this.devices).length; + return this.devices.all().length; } - getAllDevices(): Device[] { - return typedObjectKeys(this.devices).map(key => this.devices[key]); + getAllDevices() { + return this.devices.all(); } getOnlyDevice(): Device | undefined { - return this.getDeviceCount() === 1 ? Object.values(this.devices)[0] : undefined; + return this.getDeviceCount() === 1 ? this.devices.all()[0] : undefined; } getDeviceByPath(path: DeviceUniquePath): Device | undefined { - return this.getAllDevices().find(d => d.getUniquePath() === path); + return this.devices.all().find(d => d.getUniquePath() === path); } getDeviceByStaticState(state: StaticSessionId): Device | undefined { const deviceId = state.split('@')[1].split(':')[0]; - return this.getAllDevices().find(d => d.features?.device_id === deviceId); - } - - transportType() { - return this.transport.name; - } - - getTransportInfo(): TransportInfo { - return { - type: this.transportType(), - version: this.transport.version, - outdated: this.transport.isOutdated, - }; + return this.devices.all().find(d => d.features?.device_id === deviceId); } - dispose() { + async dispose() { this.removeAllListeners(); - return this.cleanup(); + const promises = typedObjectKeys(this.transport) + .concat(typedObjectKeys(this.locks)) + .filter(arrayDistinct) + .map(apiType => + this.transportLock(apiType, 'Disposing', async () => { + const transport = this.transport[apiType]; + if (transport) { + delete this.transport[apiType]; + await this.stopTransport(transport); + } + }), + ); + + await Promise.all(promises); } - async cleanup() { - const { transport } = this; - const devices = this.getAllDevices(); - - // @ts-expect-error will be fixed later - this.transport = undefined; - this.authPenaltyManager.clear(); - typedObjectKeys(this.devices).forEach(key => delete this.devices[key]); - - this.rejectPending?.(new Error('Disposed')); + private async stopTransport(transport: Transport) { + const devices = this.devices.clear(transport); // disconnect devices devices.forEach(device => { // device.disconnect(); - this.emit(DEVICE.DISCONNECT, device.toMessageObject()); + this.emit(DEVICE.DISCONNECT, device); }); // release all devices - await Promise.all(devices.map(device => device.dispose())); + await Promise.all( + devices.map(async device => { + this.authPenaltyManager.remove(device); // TODO is this right? + await device.dispose(); + }), + ); // now we can be relatively sure that release calls have been dispatched // and we can safely kill all async subscriptions in transport layer transport?.stop(); } - // TODO this is fugly - async enumerate(transport = this.transport) { - const res = await transport.enumerate(); + async enumerate() { + const promises = Object.values(this.transport).map(async transport => { + const res = await transport.enumerate(); - if (!res.success) { - return; - } + if (!res.success) { + return; + } - res.payload.forEach(d => { - this.devices[d.path]?.updateDescriptor(d); + res.payload.forEach(d => { + this.devices.get(d.path, transport)?.updateDescriptor(d); + }); }); + + await Promise.all(promises); } addAuthPenalty(device: Device) { diff --git a/packages/connect/src/device/__tests__/DeviceList.test.ts b/packages/connect/src/device/__tests__/DeviceList.test.ts index 9c90023a4d0..89315406071 100644 --- a/packages/connect/src/device/__tests__/DeviceList.test.ts +++ b/packages/connect/src/device/__tests__/DeviceList.test.ts @@ -64,28 +64,28 @@ describe('DeviceList', () => { list.dispose(); }); - it('setTransports throws error on unknown transport (string)', () => { - expect(() => - list.setTransports( + it('.init() throws error on unknown transport (string)', async () => { + await expect(() => + list.init({ // @ts-expect-error - ['FooBarTransport'], - ), - ).toThrow('unexpected type: FooBarTransport'); + transports: ['FooBarTransport'], + }), + ).rejects.toThrow('unexpected type: FooBarTransport'); }); - it('setTransports throws error on unknown transport (class)', () => { - expect(() => - list.setTransports( + it('.init() throws error on unknown transport (class)', async () => { + await expect(() => + list.init({ // @ts-expect-error - [{}, () => {}, [], String, 1, 'meow-non-existent'], - ), - ).toThrow('DeviceList.init: transports[] of unexpected type'); + transports: [{}, () => {}, [], String, 1, 'meow-non-existent'], + }), + ).rejects.toThrow('DeviceList.init: transports[] of unexpected type'); }); - it('setTransports accepts transports in form of transport class', () => { + it('.init() accepts transports in form of transport class', async () => { const transport = createTestTransport(); const classConstructor = transport.constructor as unknown as typeof transport; - expect(() => list.setTransports([classConstructor])).not.toThrow(); + await expect(list.init({ transports: [classConstructor] })).resolves.not.toThrow(); }); it('.init() throws async error from transport.init()', async () => { @@ -98,8 +98,7 @@ describe('DeviceList', () => { } as const), ); - list.setTransports([transport]); - list.init({ pendingTransportEvent: true }); + list.init({ transports: [transport], pendingTransportEvent: true }); // transport-error is not emitted yet because list.init is not awaited expect(eventsSpy).toHaveBeenCalledTimes(0); await list.pendingConnection(); @@ -116,8 +115,7 @@ describe('DeviceList', () => { } as const), ); - list.setTransports([transport]); - list.init({ pendingTransportEvent: true }); + list.init({ transports: [transport], pendingTransportEvent: true }); // transport-error is not emitted yet because list.init is not awaited expect(eventsSpy).toHaveBeenCalledTimes(0); await list.pendingConnection(); @@ -130,8 +128,7 @@ describe('DeviceList', () => { openDevice: () => Promise.resolve({ success: false, error: 'wrong previous session' }), }); - list.setTransports([transport]); - list.init({ pendingTransportEvent: true }); + list.init({ transports: [transport], pendingTransportEvent: true }); await list.pendingConnection(); const events = eventsSpy.mock.calls.map(call => call[0]); @@ -143,8 +140,7 @@ describe('DeviceList', () => { openDevice: () => Promise.resolve({ success: false, error: 'device not found' }), }); - list.setTransports([transport]); - list.init({ pendingTransportEvent: true }); + list.init({ transports: [transport], pendingTransportEvent: true }); const transportFirstEvent = list.pendingConnection(); // NOTE: this behavior is wrong, if device creation fails DeviceList shouldn't wait 10 secs. @@ -173,8 +169,7 @@ describe('DeviceList', () => { }, }); - list.setTransports([transport]); - list.init({ pendingTransportEvent: true }); + list.init({ transports: [transport], pendingTransportEvent: true }); await list.pendingConnection(); const events = eventsSpy.mock.calls.map(call => call[0]); @@ -188,8 +183,7 @@ describe('DeviceList', () => { }, }); - list.setTransports([transport]); - list.init({ pendingTransportEvent: true }); + list.init({ transports: [transport], pendingTransportEvent: true }); await list.pendingConnection(); const events = eventsSpy.mock.calls.map(([event, { path }]) => [event, path]); @@ -220,8 +214,7 @@ describe('DeviceList', () => { // NOTE: this behavior is wrong jest.useFakeTimers(); - list.setTransports([transport]); - list.init({ pendingTransportEvent: true }); + list.init({ transports: [transport], pendingTransportEvent: true }); const transportFirstEvent = list.pendingConnection(); await jest.advanceTimersByTimeAsync(6 * 1000); // TODO: this is wrong await transportFirstEvent; @@ -240,8 +233,7 @@ describe('DeviceList', () => { it('.init() without pendingTransportEvent (device connected after start)', async () => { const transport = createTestTransport(); - list.setTransports([transport]); - list.init(); + list.init({ transports: [transport] }); await list.pendingConnection(); // transport start emitted almost immediately (after first enumerate) expect(eventsSpy).toHaveBeenCalledTimes(1); @@ -266,8 +258,7 @@ describe('DeviceList', () => { }, }); - list.setTransports([transport]); - list.init({ pendingTransportEvent: true }); + list.init({ transports: [transport], pendingTransportEvent: true }); await list.pendingConnection(); // emit TRANSPORT.CHANGE 3 times diff --git a/packages/connect/src/events/core.ts b/packages/connect/src/events/core.ts index 4a53146290e..0e4d680112b 100644 --- a/packages/connect/src/events/core.ts +++ b/packages/connect/src/events/core.ts @@ -7,6 +7,7 @@ import type { TransportEventMessage, TransportDisableWebUSB, TransportRequestWebUSBDevice, + TransportSetTransports, TransportGetInfo, } from './transport'; import type { UiEventMessage } from './ui-request'; @@ -20,6 +21,7 @@ export type CoreRequestMessage = | PopupClosedMessage | PopupAnalyticsResponse | TransportDisableWebUSB + | TransportSetTransports | TransportRequestWebUSBDevice | TransportGetInfo | UiResponseEvent diff --git a/packages/connect/src/events/popup.ts b/packages/connect/src/events/popup.ts index d8d5ba3cf38..575ab954736 100644 --- a/packages/connect/src/events/popup.ts +++ b/packages/connect/src/events/popup.ts @@ -51,7 +51,7 @@ export interface PopupHandshake { type: typeof POPUP.HANDSHAKE; payload: { settings: ConnectSettings; // those are settings from the iframe, they could be different from window.opener settings - transport?: TransportInfo; + transports?: TransportInfo[]; }; } diff --git a/packages/connect/src/events/transport.ts b/packages/connect/src/events/transport.ts index f0d83f8442f..c2a0e8b7eb0 100644 --- a/packages/connect/src/events/transport.ts +++ b/packages/connect/src/events/transport.ts @@ -3,6 +3,7 @@ import { TRANSPORT } from '@trezor/transport/src/constants'; import { serializeError } from '../constants/errors'; import type { MessageFactoryFn } from '../types/utils'; +import { ConnectSettings } from '../exports'; export { TRANSPORT } from '@trezor/transport/src/constants'; @@ -39,6 +40,11 @@ export interface TransportInfo { udev?: UdevInfo; } +export type TransportTypeState = + | { type: Transport['apiType']; status: 'stopped'; name?: undefined; error?: undefined } + | { type: Transport['apiType']; status: 'running'; name: Transport['name']; error?: undefined } + | { type: Transport['apiType']; status: 'error'; name?: undefined; error: string }; + export type TransportEvent = | { type: typeof TRANSPORT.START; @@ -52,8 +58,17 @@ export type TransportEvent = bridge?: BridgeInfo; udev?: UdevInfo; }; + } + | { + type: typeof TRANSPORT.CHANGED; + payload: TransportTypeState; }; +export interface TransportSetTransports { + type: typeof TRANSPORT.SET_TRANSPORTS; + payload: Pick; +} + export interface TransportDisableWebUSB { type: typeof TRANSPORT.DISABLE_WEBUSB; payload?: undefined; diff --git a/packages/connect/src/factory.ts b/packages/connect/src/factory.ts index 30083916ada..507e0aba506 100644 --- a/packages/connect/src/factory.ts +++ b/packages/connect/src/factory.ts @@ -10,6 +10,7 @@ export interface ConnectFactoryDependencies & { init: InitType } & ExtraMethodsType => ({ manifest, init, + setTransports, on: any>(type: T, fn: P) => { eventEmitter.on(type, fn); @@ -107,6 +110,8 @@ export const factory = < composeTransaction: params => call({ ...params, method: 'composeTransaction' }), + eraseBonds: params => call({ ...params, method: 'eraseBonds' }), + ethereumGetAddress: params => call({ ...params, diff --git a/packages/connect/src/impl/core-in-module.ts b/packages/connect/src/impl/core-in-module.ts index be7d959cb19..c329f7024c8 100644 --- a/packages/connect/src/impl/core-in-module.ts +++ b/packages/connect/src/impl/core-in-module.ts @@ -189,6 +189,11 @@ export class CoreInModule implements ConnectFactoryDependencies = {}) => { this._settings = parseConnectSettings({ ...this._settings, @@ -287,6 +292,7 @@ export const TrezorConnect = factory({ manifest: impl.manifest.bind(impl), init: impl.init.bind(impl), call: impl.call.bind(impl), + setTransports: impl.setTransports.bind(impl), requestLogin: impl.requestLogin.bind(impl), uiResponse: impl.uiResponse.bind(impl), cancel: impl.cancel.bind(impl), diff --git a/packages/connect/src/impl/dynamic.ts b/packages/connect/src/impl/dynamic.ts index 30a13f388f7..d911503059d 100644 --- a/packages/connect/src/impl/dynamic.ts +++ b/packages/connect/src/impl/dynamic.ts @@ -114,6 +114,11 @@ export class TrezorConnectDynamic< } } + public setTransports() { + // TODO: implement + throw new Error('Unsupported right now'); + } + public async call(params: CallMethodPayload) { const response = await this.getTarget().call(params); if (!response.success) { diff --git a/packages/connect/src/index-browser.ts b/packages/connect/src/index-browser.ts index bdf93de6ff4..87fc33fa134 100644 --- a/packages/connect/src/index-browser.ts +++ b/packages/connect/src/index-browser.ts @@ -15,6 +15,7 @@ const TrezorConnect = factory({ } as any, manifest: fallback, init: fallback, + setTransports: fallback, call: fallback, requestLogin: fallback, uiResponse: fallback, diff --git a/packages/connect/src/index.ts b/packages/connect/src/index.ts index 96988bcf66f..116a38797cc 100644 --- a/packages/connect/src/index.ts +++ b/packages/connect/src/index.ts @@ -20,6 +20,7 @@ import { UI, UiResponseEvent, CallMethod, + TRANSPORT, } from './events'; import { ERRORS } from './constants'; import type { ConnectSettings, Manifest } from './types'; @@ -149,6 +150,14 @@ const call: CallMethod = async params => { } }; +const setTransports = (payload: Pick) => { + const core = coreManager.get(); + if (!core) { + throw ERRORS.TypedError('Init_NotInitialized'); + } + core.handleMessage({ type: TRANSPORT.SET_TRANSPORTS, payload }); +}; + const uiResponse = (response: UiResponseEvent) => { const core = coreManager.get(); if (!core) { @@ -214,6 +223,7 @@ const TrezorConnect = factory( manifest, init, call, + setTransports, requestLogin, uiResponse, cancel, diff --git a/packages/connect/src/types/api/eraseBonds.ts b/packages/connect/src/types/api/eraseBonds.ts new file mode 100644 index 00000000000..7ff48a00e68 --- /dev/null +++ b/packages/connect/src/types/api/eraseBonds.ts @@ -0,0 +1,4 @@ +import type { PROTO } from '../../constants'; +import type { Params, Response } from '../params'; + +export declare function eraseBonds(params: Params): Response; diff --git a/packages/connect/src/types/api/index.ts b/packages/connect/src/types/api/index.ts index 12900e124d6..5c625a42b3f 100644 --- a/packages/connect/src/types/api/index.ts +++ b/packages/connect/src/types/api/index.ts @@ -40,6 +40,7 @@ import { ethereumSignMessage } from './ethereumSignMessage'; import { ethereumSignTransaction } from './ethereumSignTransaction'; import { ethereumSignTypedData } from './ethereumSignTypedData'; import { ethereumVerifyMessage } from './ethereumVerifyMessage'; +import { eraseBonds } from './eraseBonds'; import { firmwareUpdate } from './firmwareUpdate'; import { getAccountDescriptor } from './getAccountDescriptor'; import { getAccountInfo } from './getAccountInfo'; @@ -69,6 +70,7 @@ import { rippleSignTransaction } from './rippleSignTransaction'; import { setBrightness } from './setBrightness'; import { setBusy } from './setBusy'; import { setProxy } from './setProxy'; +import { setTransports } from './setTransports'; import { showDeviceTutorial } from './showDeviceTutorial'; import { signMessage } from './signMessage'; import { signTransaction } from './signTransaction'; @@ -215,6 +217,9 @@ export interface TrezorConnect { // https://connect.trezor.io/9/methods/ethereum/ethereumVerifyMessage/ ethereumVerifyMessage: typeof ethereumVerifyMessage; + // https://connect.trezor.io/9/methods/device/eraseBonds/ + eraseBonds: typeof eraseBonds; + // https://connect.trezor.io/9/methods/device/firmwareUpdate/ firmwareUpdate: typeof firmwareUpdate; @@ -302,6 +307,9 @@ export interface TrezorConnect { // todo: link docs setProxy: typeof setProxy; + // todo: link docs + setTransports: typeof setTransports; + // https://connect.trezor.io/9/methods/bitcoin/signMessage/ signMessage: typeof signMessage; diff --git a/packages/connect/src/types/api/setTransports.ts b/packages/connect/src/types/api/setTransports.ts new file mode 100644 index 00000000000..bd3c5b337bb --- /dev/null +++ b/packages/connect/src/types/api/setTransports.ts @@ -0,0 +1,9 @@ +/** + * Change transports for communication with devices + */ + +import type { ConnectSettings } from '../settings'; + +export type SetTransports = Pick; + +export declare function setTransports(params: SetTransports): void; diff --git a/packages/connect/src/types/device.ts b/packages/connect/src/types/device.ts index df1f09120e9..e854559202f 100644 --- a/packages/connect/src/types/device.ts +++ b/packages/connect/src/types/device.ts @@ -81,6 +81,10 @@ type BaseDevice = { name: string; }; +export type BluetoothDeviceProps = { + uuid: string; +}; + export type KnownDevice = BaseDevice & { type: 'acquired'; id: string | null; @@ -105,6 +109,7 @@ export type KnownDevice = BaseDevice & { }; transportSessionOwner?: undefined; transportDescriptorType?: typeof undefined; + bluetoothProps?: BluetoothDeviceProps; }; export type UnknownDevice = BaseDevice & { @@ -126,6 +131,7 @@ export type UnknownDevice = BaseDevice & { availableTranslations?: typeof undefined; transportSessionOwner?: string; transportDescriptorType?: typeof undefined; + bluetoothProps?: BluetoothDeviceProps; }; export type UnreadableDevice = BaseDevice & { @@ -147,6 +153,7 @@ export type UnreadableDevice = BaseDevice & { availableTranslations?: typeof undefined; transportSessionOwner?: undefined; transportDescriptorType: Descriptor['type']; + bluetoothProps?: BluetoothDeviceProps; }; export type Device = KnownDevice | UnknownDevice | UnreadableDevice; diff --git a/packages/connect/src/utils/promiseUtils.ts b/packages/connect/src/utils/promiseUtils.ts index 9361ecdc93b..12b8f3c1baa 100644 --- a/packages/connect/src/utils/promiseUtils.ts +++ b/packages/connect/src/utils/promiseUtils.ts @@ -11,3 +11,15 @@ export const resolveAfter = (msec: number, value?: T) => { reject, }; }; + +export const abortablePromise = (signal: AbortSignal) => { + const { promise, reject, resolve } = createDeferred(); + const onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort); + if (signal.aborted) onAbort(); + + return { + promise: promise.finally(() => signal.removeEventListener('abort', onAbort)), + resolve, + }; +}; diff --git a/packages/eslint/src/index.mjs b/packages/eslint/src/index.mjs index 3a748e2293c..0f6b6710730 100644 --- a/packages/eslint/src/index.mjs +++ b/packages/eslint/src/index.mjs @@ -27,6 +27,7 @@ export const eslint = [ '**/ci/', '**/.expo/*', 'eslint-local-rules/*', + '**/.cache/*', ], }, { files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'] }, diff --git a/packages/product-components/src/components/RotateDeviceImage/RotateDeviceImage.tsx b/packages/product-components/src/components/RotateDeviceImage/RotateDeviceImage.tsx index 7c6407a3dc0..f9673325e1e 100644 --- a/packages/product-components/src/components/RotateDeviceImage/RotateDeviceImage.tsx +++ b/packages/product-components/src/components/RotateDeviceImage/RotateDeviceImage.tsx @@ -32,9 +32,12 @@ export const RotateDeviceImage = ({ const isDeviceImageRotating = deviceModel && - [DeviceModelInternal.T2B1, DeviceModelInternal.T3B1, DeviceModelInternal.T3T1].includes( - deviceModel, - ); + [ + DeviceModelInternal.T2B1, + DeviceModelInternal.T3B1, + DeviceModelInternal.T3T1, + DeviceModelInternal.T3W1, + ].includes(deviceModel); return ( <> diff --git a/packages/protobuf/messages.json b/packages/protobuf/messages.json index f5427b7c8d2..ce51e51b602 100644 --- a/packages/protobuf/messages.json +++ b/packages/protobuf/messages.json @@ -1,5 +1,8 @@ { "nested": { + "EraseBonds": { + "fields": {} + }, "BinanceGetAddress": { "fields": { "address_n": { @@ -6883,6 +6886,7 @@ "BinanceOrderMsg": 707, "BinanceCancelMsg": 708, "BinanceSignedTx": 709, + "EraseBonds": 8006, "SolanaGetPublicKey": 900, "SolanaPublicKey": 901, "SolanaGetAddress": 902, diff --git a/packages/protobuf/src/messages-schema.ts b/packages/protobuf/src/messages-schema.ts index 815dc9887e2..6f082ee4c17 100644 --- a/packages/protobuf/src/messages-schema.ts +++ b/packages/protobuf/src/messages-schema.ts @@ -2446,6 +2446,9 @@ export const ApplyFlags = Type.Object( { $id: 'ApplyFlags' }, ); +export type EraseBonds = Static; +export const EraseBonds = Type.Object({}); + export type ChangePin = Static; export const ChangePin = Type.Object( { @@ -3509,6 +3512,7 @@ export const TezosSignedTx = Type.Object( export type MessageType = Static; export const MessageType = Type.Object( { + EraseBonds, BinanceGetAddress, BinanceAddress, BinanceGetPublicKey, diff --git a/packages/protobuf/src/messages.ts b/packages/protobuf/src/messages.ts index 2531a6c4f05..74e93d2999f 100644 --- a/packages/protobuf/src/messages.ts +++ b/packages/protobuf/src/messages.ts @@ -15,6 +15,8 @@ export enum DeviceModelInternal { T3W1 = 'T3W1', } +export type EraseBonds = {}; + export type BinanceGetAddress = { address_n: number[]; show_display?: boolean; @@ -2271,6 +2273,7 @@ export type TezosSignedTx = { // custom connect definitions export type MessageType = { + EraseBonds: EraseBonds; BinanceGetAddress: BinanceGetAddress; BinanceAddress: BinanceAddress; BinanceGetPublicKey: BinanceGetPublicKey; diff --git a/packages/suite-build/configs/desktop.webpack.config.ts b/packages/suite-build/configs/desktop.webpack.config.ts index b249c022a97..4cab2498ece 100644 --- a/packages/suite-build/configs/desktop.webpack.config.ts +++ b/packages/suite-build/configs/desktop.webpack.config.ts @@ -49,6 +49,33 @@ const config: webpack.Configuration = { to: path.join(baseDir, 'build/static/bin/firmware'), }, ]) + // include bluetooth binaries from @trezor/transport-bluetooth + .concat([ + { + from: path.join(__dirname, '../../', 'transport-bluetooth/bin/linux'), + to: path.join(baseDir, 'build/static/bin/bluetooth/linux-x64'), + }, + { + from: path.join(__dirname, '../../', 'transport-bluetooth/bin/linux'), + to: path.join(baseDir, 'build/static/bin/bluetooth/linux-arm64'), + }, + { + from: path.join(__dirname, '../../', 'transport-bluetooth/bin/macos'), + to: path.join(baseDir, 'build/static/bin/bluetooth/mac-x64'), + }, + { + from: path.join(__dirname, '../../', 'transport-bluetooth/bin/macos'), + to: path.join(baseDir, 'build/static/bin/bluetooth/mac-arm64'), + }, + { + from: path.join(__dirname, '../../', 'transport-bluetooth/bin/windows'), + to: path.join(baseDir, 'build/static/bin/bluetooth/win-x64'), + }, + { + from: path.join(__dirname, '../../', 'transport-bluetooth/bin/windows'), + to: path.join(baseDir, 'build/static/bin/bluetooth/win-arm64'), + }, + ]) .concat( isCodesignBuild ? [] diff --git a/packages/suite-desktop-api/src/api.ts b/packages/suite-desktop-api/src/api.ts index 6c810a72b39..91ccd691cc9 100644 --- a/packages/suite-desktop-api/src/api.ts +++ b/packages/suite-desktop-api/src/api.ts @@ -109,6 +109,7 @@ export interface InvokeChannels { 'connect-popup/ready': () => void; 'connect-popup/response': (response: ConnectPopupResponse) => void; 'system/get-system-information': () => InvokeResult; + 'bluetooth/open-settings': () => InvokeResult; } type DesktopApiListener = ListenerMethod; @@ -177,4 +178,5 @@ export interface DesktopApi { connectPopupResponse: DesktopApiInvoke<'connect-popup/response'>; //system getSystemInformation: DesktopApiInvoke<'system/get-system-information'>; + bluetoothOpenSettings: DesktopApiInvoke<'bluetooth/open-settings'>; } diff --git a/packages/suite-desktop-api/src/factory.ts b/packages/suite-desktop-api/src/factory.ts index 17269e44fa1..33296b42fbb 100644 --- a/packages/suite-desktop-api/src/factory.ts +++ b/packages/suite-desktop-api/src/factory.ts @@ -184,5 +184,7 @@ export const factory = >( connectPopupResponse: response => ipcRenderer.invoke('connect-popup/response', response), getSystemInformation: () => ipcRenderer.invoke('system/get-system-information'), + // Bluetooth + bluetoothOpenSettings: () => ipcRenderer.invoke('bluetooth/open-settings'), }; }; diff --git a/packages/suite-desktop-core/package.json b/packages/suite-desktop-core/package.json index be0a543d8bd..54e89b46770 100644 --- a/packages/suite-desktop-core/package.json +++ b/packages/suite-desktop-core/package.json @@ -32,6 +32,7 @@ "@trezor/request-manager": "workspace:*", "@trezor/suite": "workspace:*", "@trezor/suite-desktop-api": "workspace:*", + "@trezor/transport-bluetooth": "workspace:*", "@trezor/transport-bridge": "workspace:*", "@trezor/urls": "workspace:*", "@trezor/utils": "workspace:*", diff --git a/packages/suite-desktop-core/src/libs/processes/BaseProcess.ts b/packages/suite-desktop-core/src/libs/processes/BaseProcess.ts index c557e71d793..e3d9d44a485 100644 --- a/packages/suite-desktop-core/src/libs/processes/BaseProcess.ts +++ b/packages/suite-desktop-core/src/libs/processes/BaseProcess.ts @@ -1,6 +1,6 @@ import { app } from 'electron'; import path from 'path'; -import { spawn, ChildProcess } from 'child_process'; +import { spawn, ChildProcess, IOType } from 'child_process'; import { TimerId } from '@trezor/type-utils'; @@ -20,6 +20,7 @@ export type Options = { startupCooldown?: number; stopKillWait?: number; autoRestart?: number; + stdio?: [IOType, IOType, IOType]; }; const defaultOptions: Options = { @@ -146,10 +147,18 @@ export abstract class BaseProcess { this.process = spawn(processPath, params, { cwd: processDir, env: processEnv, - stdio: ['ignore', 'ignore', 'ignore'], + stdio: this.options.stdio || ['ignore', 'ignore', 'ignore'], }); this.process.on('error', err => this.onError(err)); this.process.on('exit', code => this.onExit(code)); + // if (this.options.stdio) { + // this.process.stdout?.on('data', d => { + // console.warn('STDOUT DATA', d.toString()); + // }); + // this.process.stderr?.on('data', d => { + // console.warn('STDERR DATA', d.toString()); + // }); + // } if (this.options.autoRestart && this.options.autoRestart > 0) { // When process runs with `autoRestart`, restarting the process is managed by BaseProcess. diff --git a/packages/suite-desktop-core/src/libs/processes/BluetoothProcess.ts b/packages/suite-desktop-core/src/libs/processes/BluetoothProcess.ts new file mode 100644 index 00000000000..a116f2d0ab0 --- /dev/null +++ b/packages/suite-desktop-core/src/libs/processes/BluetoothProcess.ts @@ -0,0 +1,67 @@ +import { BaseProcess, Status } from './BaseProcess'; + +export class BluetoothProcess extends BaseProcess { + private readonly port; + + constructor(port = 21327) { + super('bluetooth', 'trezor-ble', { + stdio: + process.platform.indexOf('win') >= 0 + ? undefined + : ['inherit', 'inherit', 'inherit'], + }); + this.port = port; + } + + getUrl() { + return `http://localhost:${this.port}/`; + } + + getPort() { + return this.port; + } + + async status(): Promise { + if (!this.process) { + return { + service: false, + process: false, + }; + } + + // service + try { + const resp = await fetch(`http://127.0.0.1:21327/`, { + method: 'POST', + headers: { + Origin: 'https://electron.trezor.io', + }, + }); + this.logger.debug(this.logTopic, `Checking status (${resp.status})`); + if (resp.status === 200) { + const data = await resp.json(); + if (data?.version) { + return { + service: true, + process: true, + }; + } + } + } catch (err) { + this.logger.debug(this.logTopic, `Status error: ${err.message}`); + } + + // process + return { + service: false, + process: Boolean(this.process), + }; + } + + start() { + process.env.RUST_LOG = 'debug'; + process.env.RUST_BACKTRACE = '1'; + + return super.start(); + } +} diff --git a/packages/suite-desktop-core/src/modules/bluetooth.ts b/packages/suite-desktop-core/src/modules/bluetooth.ts new file mode 100644 index 00000000000..8ef80ca0414 --- /dev/null +++ b/packages/suite-desktop-core/src/modules/bluetooth.ts @@ -0,0 +1,123 @@ +/** + * Uses @trezor/transport-bluetooth package in nodejs context + */ +import { exec } from 'child_process'; + +import { getFreePort } from '@trezor/node-utils'; +import { createIpcProxyHandler, IpcProxyHandlerOptions } from '@trezor/ipc-proxy'; +import { isMacOs, isLinux, isWindows } from '@trezor/env-utils'; +import { BluetoothIpcApi, BluetoothApiImpl as BluetoothApi } from '@trezor/transport-bluetooth'; + +import { BluetoothProcess } from '../libs/processes/BluetoothProcess'; +import { ipcMain } from '../typed-electron'; + +import type { ModuleInit } from './index'; + +export const SERVICE_NAME = '@trezor/transport-bluetooth'; + +export const init: ModuleInit = () => { + const { logger } = global; + + ipcMain.handle('bluetooth/open-settings', () => { + // TODO: catch exec errors, maybe move somewhere can be used to open camera settings + if (isMacOs()) { + exec('open "x-apple.systempreferences:com.apple.Bluetooth"', error => { + if (error) { + console.error(`Error opening Bluetooth settings: ${error}`); + + return { success: false, error: 'Unsupported os' }; + } + }); + + return { success: true }; + } + + if (isLinux()) { + exec( + 'gnome-control-center bluetooth', + { env: { ...process.env, DISPLAY: ':0', XDG_CURRENT_DESKTOP: 'GNOME' } }, + error => { + if (error) { + console.error(`Error opening Bluetooth settings: ${error}`); + + return { success: false, error: 'Unsupported os' }; + } + }, + ); + + return { success: true }; + } + + if (isWindows()) { + exec('start ms-settings:bluetooth', error => { + if (error) { + console.error(`Error opening Bluetooth settings: ${error}`); + + return { success: false, error: 'Unsupported os' }; + } + }); + + return { success: true }; + } + + return { success: false, error: 'Unsupported os' }; + }); + + const clientProxyOptions: IpcProxyHandlerOptions = { + onCreateInstance() { + const api = new BluetoothApi({}); // logger + + return { + onRequest: (method, params) => { + logger.debug(SERVICE_NAME, `call ${method}`); + + return (api[method] as any)(...params); + }, + onAddListener: (eventName, listener) => { + logger.debug(SERVICE_NAME, `add listener ${eventName}`); + + return api.on(eventName, listener); + }, + onRemoveListener: (eventName: any) => { + logger.debug(SERVICE_NAME, `remove listener ${eventName}`); + + // return Promise.resolve(); + return api.removeAllListeners(eventName); + }, + }; + }, + }; + + const unregisterProxy = createIpcProxyHandler(ipcMain as any, 'Bluetooth', clientProxyOptions); + + let bluetoothProcess: BluetoothProcess | undefined; + + const getBluetoothProcess = async () => { + if (!bluetoothProcess) { + const port = await getFreePort(); + bluetoothProcess = new BluetoothProcess(port); + } + + return bluetoothProcess; + }; + + const killBluetoothProcess = () => { + if (bluetoothProcess) { + bluetoothProcess.stop(); + bluetoothProcess = undefined; + } + }; + + const onLoad = async () => { + const btProcess = await getBluetoothProcess(); + await btProcess.start(); + }; + + const onQuit = () => { + logger.info(SERVICE_NAME, 'Stopping (app quit)'); + killBluetoothProcess(); + unregisterProxy(); + }; + + return { onLoad, onQuit }; +}; diff --git a/packages/suite-desktop-core/src/modules/index.ts b/packages/suite-desktop-core/src/modules/index.ts index c643741dc19..1b9d9331bb6 100644 --- a/packages/suite-desktop-core/src/modules/index.ts +++ b/packages/suite-desktop-core/src/modules/index.ts @@ -35,6 +35,7 @@ import * as autoStart from './auto-start'; import * as tray from './tray'; import * as bridge from './bridge'; import * as systemInformation from './system-information'; +import * as bluetooth from './bluetooth'; import { MainWindowProxy } from '../libs/main-window-proxy'; // General modules (both dev & prod) @@ -65,6 +66,7 @@ const MODULES: Module[] = [ autoStart, bridge, systemInformation, + bluetooth, // Modules used only in dev/prod mode ...(isDevEnv ? [] : [csp, fileProtocol]), ]; diff --git a/packages/suite-desktop-core/src/modules/trezor-connect.ts b/packages/suite-desktop-core/src/modules/trezor-connect.ts index eb8c1d375cf..42dcce27047 100644 --- a/packages/suite-desktop-core/src/modules/trezor-connect.ts +++ b/packages/suite-desktop-core/src/modules/trezor-connect.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron'; import TrezorConnect, { DEVICE_EVENT } from '@trezor/connect'; import { createIpcProxyHandler, IpcProxyHandlerOptions } from '@trezor/ipc-proxy'; +import { BluetoothTransport } from '@trezor/transport-bluetooth'; import { ModuleInit, ModuleInitBackground } from './index'; @@ -25,6 +26,13 @@ export const initBackground: ModuleInitBackground = ({ mainThreadEmitter, store onRequest: async (method, params) => { logger.debug(SERVICE_NAME, `call ${method}`); if (method === 'init') { + const transports = params[0].transports || []; + if (transports.length === 0) { + transports.push('BridgeTransport'); + } + transports.push(new BluetoothTransport({ id: 'BT' })); + params[0].transports = transports; + const response = await TrezorConnect[method](...params); await setProxy(true); diff --git a/packages/suite-desktop-core/src/preload.ts b/packages/suite-desktop-core/src/preload.ts index 5f14dac9ccf..b854c4f6b06 100644 --- a/packages/suite-desktop-core/src/preload.ts +++ b/packages/suite-desktop-core/src/preload.ts @@ -6,7 +6,12 @@ import { getDesktopApi } from '@trezor/suite-desktop-api'; import '@sentry/electron/preload'; // With this only IPCMode.Classic is ever taken into account contextBridge.exposeInMainWorld( - ...exposeIpcProxy(ipcRenderer, ['TrezorConnect', 'CoinjoinBackend', 'CoinjoinClient']), + ...exposeIpcProxy(ipcRenderer, [ + 'TrezorConnect', + 'CoinjoinBackend', + 'CoinjoinClient', + 'Bluetooth', + ]), ); const desktopApi = getDesktopApi(ipcRenderer); diff --git a/packages/suite-desktop-core/tsconfig.json b/packages/suite-desktop-core/tsconfig.json index 188eb001401..4819d2e0110 100644 --- a/packages/suite-desktop-core/tsconfig.json +++ b/packages/suite-desktop-core/tsconfig.json @@ -33,6 +33,7 @@ { "path": "../request-manager" }, { "path": "../suite" }, { "path": "../suite-desktop-api" }, + { "path": "../transport-bluetooth" }, { "path": "../transport-bridge" }, { "path": "../urls" }, { "path": "../utils" }, diff --git a/packages/suite-desktop-ui/src/Main.tsx b/packages/suite-desktop-ui/src/Main.tsx index efbc6504ea7..31c62361d4e 100644 --- a/packages/suite-desktop-ui/src/Main.tsx +++ b/packages/suite-desktop-ui/src/Main.tsx @@ -30,6 +30,7 @@ import { useDebugLanguageShortcut, useFormattersConfig } from 'src/hooks/suite'; import history from 'src/support/history'; import { ModalContextProvider } from 'src/support/suite/ModalContext'; import { desktopHandshake } from 'src/actions/suite/suiteActions'; +import { initBluetoothThunk } from 'src/actions/bluetooth/initBluetoothThunk'; import * as STORAGE from 'src/actions/suite/constants/storageConstants'; import { DesktopUpdater } from './support/DesktopUpdater'; @@ -134,6 +135,8 @@ export const init = async (container: HTMLElement) => { TrezorConnect[method] = proxy[method]; }); + store.dispatch(initBluetoothThunk()); + // finally render whole app root.render( diff --git a/packages/suite-desktop/electron-builder-config.js b/packages/suite-desktop/electron-builder-config.js index b7021e14e70..ab9c3145d95 100644 --- a/packages/suite-desktop/electron-builder-config.js +++ b/packages/suite-desktop/electron-builder-config.js @@ -95,6 +95,10 @@ module.exports = { from: 'build/static/bin/coinjoin/mac-${arch}', to: 'bin/coinjoin', }, + { + from: 'build/static/bin/bluetooth/mac-${arch}', + to: 'bin/bluetooth', + }, ], icon: 'build/static/images/desktop/512x512.icns', artifactName: 'Trezor-Suite-${version}-mac-${arch}.${ext}', @@ -129,6 +133,10 @@ module.exports = { from: 'build/static/bin/coinjoin/win-${arch}', to: 'bin/coinjoin', }, + { + from: 'build/static/bin/bluetooth/win-${arch}', + to: 'bin/bluetooth', + }, ], icon: 'build/static/images/desktop/512x512.png', artifactName: 'Trezor-Suite-${version}-win-${arch}.${ext}', @@ -154,6 +162,10 @@ module.exports = { from: 'build/static/bin/coinjoin/linux-${arch}', to: 'bin/coinjoin', }, + { + from: 'build/static/bin/bluetooth/linux-${arch}', + to: 'bin/bluetooth', + }, ], icon: 'build/static/images/desktop/512x512.png', artifactName: 'Trezor-Suite-${version}-linux-${arch}.${ext}', diff --git a/packages/suite-desktop/entitlements.mac.inherit.plist b/packages/suite-desktop/entitlements.mac.inherit.plist index acf1a1a4d7e..f288240ba12 100644 --- a/packages/suite-desktop/entitlements.mac.inherit.plist +++ b/packages/suite-desktop/entitlements.mac.inherit.plist @@ -10,5 +10,7 @@ com.apple.security.device.camera + NSBluetoothAlwaysUsageDescription + Our app uses bluetooth to find, connect and transfer data between different devices \ No newline at end of file diff --git a/packages/suite/package.json b/packages/suite/package.json index 4d98b750653..3c649d65ae7 100644 --- a/packages/suite/package.json +++ b/packages/suite/package.json @@ -77,6 +77,7 @@ "@trezor/suite-desktop-api": "workspace:*", "@trezor/suite-storage": "workspace:*", "@trezor/theme": "workspace:*", + "@trezor/transport-bluetooth": "workspace:*", "@trezor/type-utils": "workspace:*", "@trezor/urls": "workspace:*", "@trezor/utils": "workspace:*", diff --git a/packages/suite/src/actions/bluetooth/bluetoothActions.ts b/packages/suite/src/actions/bluetooth/bluetoothActions.ts new file mode 100644 index 00000000000..ce9266a9851 --- /dev/null +++ b/packages/suite/src/actions/bluetooth/bluetoothActions.ts @@ -0,0 +1,39 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { BluetoothDevice } from '@trezor/transport-bluetooth'; + +import { + BluetoothScanStatus, + DeviceBluetoothStatus, +} from '../../reducers/bluetooth/bluetoothReducer'; + +export const BLUETOOTH_PREFIX = '@suite/bluetooth'; + +export const bluetoothAdapterEventAction = createAction( + `${BLUETOOTH_PREFIX}/adapter-event`, + ({ isPowered }: { isPowered: boolean }) => ({ payload: { isPowered } }), +); + +export const bluetoothDeviceListUpdate = createAction( + `${BLUETOOTH_PREFIX}/device-list-update`, + ({ devices }: { devices: BluetoothDevice[] }) => ({ payload: { devices } }), +); + +export const bluetoothConnectDeviceEventAction = createAction( + `${BLUETOOTH_PREFIX}/device-connection-status`, + ({ connectionStatus, uuid }: { uuid: string; connectionStatus: DeviceBluetoothStatus }) => ({ + payload: { uuid, connectionStatus }, + }), +); + +export const bluetoothScanStatusAction = createAction( + `${BLUETOOTH_PREFIX}/scan-status`, + ({ status }: { status: BluetoothScanStatus }) => ({ payload: { status } }), +); + +export const allBluetoothActions = { + bluetoothAdapterEventAction, + bluetoothDeviceListUpdate, + bluetoothConnectDeviceEventAction, + bluetoothScanStatusAction, +}; diff --git a/packages/suite/src/actions/bluetooth/bluetoothConnectDeviceThunk.ts b/packages/suite/src/actions/bluetooth/bluetoothConnectDeviceThunk.ts new file mode 100644 index 00000000000..3f523c06a5e --- /dev/null +++ b/packages/suite/src/actions/bluetooth/bluetoothConnectDeviceThunk.ts @@ -0,0 +1,15 @@ +import { createThunk } from '@suite-common/redux-utils'; +import { bluetoothManager } from '@trezor/transport-bluetooth'; + +import { BLUETOOTH_PREFIX } from './bluetoothActions'; + +type ThunkResponse = ReturnType; + +export const bluetoothConnectDeviceThunk = createThunk( + `${BLUETOOTH_PREFIX}/bluetoothConnectDeviceThunk`, + async ({ uuid }, { fulfillWithValue }) => { + const result = await bluetoothManager.connectDevice(uuid); + + return fulfillWithValue(result); + }, +); diff --git a/packages/suite/src/actions/bluetooth/bluetoothStartScanningThunk.ts b/packages/suite/src/actions/bluetooth/bluetoothStartScanningThunk.ts new file mode 100644 index 00000000000..ba6d76792dc --- /dev/null +++ b/packages/suite/src/actions/bluetooth/bluetoothStartScanningThunk.ts @@ -0,0 +1,13 @@ +import { createThunk } from '@suite-common/redux-utils'; +import { bluetoothManager } from '@trezor/transport-bluetooth'; + +import { BLUETOOTH_PREFIX } from './bluetoothActions'; + +export const bluetoothStartScanningThunk = createThunk( + `${BLUETOOTH_PREFIX}/bluetoothStartScanningThunk`, + _ => { + // This can fail, but if there is an error we already got it from `adapter-event` + // and user is informed about it (bluetooth turned-off, ...) + bluetoothManager.startScan(); + }, +); diff --git a/packages/suite/src/actions/bluetooth/bluetoothStopScanningThunk.ts b/packages/suite/src/actions/bluetooth/bluetoothStopScanningThunk.ts new file mode 100644 index 00000000000..fc8ddeead8b --- /dev/null +++ b/packages/suite/src/actions/bluetooth/bluetoothStopScanningThunk.ts @@ -0,0 +1,12 @@ +import { createThunk } from '@suite-common/redux-utils'; +import { bluetoothManager } from '@trezor/transport-bluetooth'; + +import { BLUETOOTH_PREFIX } from './bluetoothActions'; + +export const bluetoothStopScanningThunk = createThunk( + `${BLUETOOTH_PREFIX}/bluetoothStopScanningThunk`, + _ => { + // This can fail, but there is nothing we can do about it + bluetoothManager.stopScan(); + }, +); diff --git a/packages/suite/src/actions/bluetooth/initBluetoothThunk.ts b/packages/suite/src/actions/bluetooth/initBluetoothThunk.ts new file mode 100644 index 00000000000..e553e327ec8 --- /dev/null +++ b/packages/suite/src/actions/bluetooth/initBluetoothThunk.ts @@ -0,0 +1,44 @@ +import { createThunk } from '@suite-common/redux-utils/'; +import { bluetoothManager, DeviceConnectionStatus } from '@trezor/transport-bluetooth'; +import { Without } from '@trezor/type-utils'; + +import { + BLUETOOTH_PREFIX, + bluetoothAdapterEventAction, + bluetoothConnectDeviceEventAction, + bluetoothDeviceListUpdate, +} from './bluetoothActions'; + +type DeviceConnectionStatusWithOptionalUuid = Without & { + uuid?: string; +}; + +export const initBluetoothThunk = createThunk( + `${BLUETOOTH_PREFIX}/initBluetoothThunk`, + (_, { dispatch }) => { + bluetoothManager.on('adapter-event', isPowered => { + console.warn('adapter-event', isPowered); + dispatch(bluetoothAdapterEventAction({ isPowered })); + }); + + bluetoothManager.on('device-list-update', devices => { + console.warn('device-list-update', devices); + dispatch(bluetoothDeviceListUpdate({ devices })); + }); + + bluetoothManager.on('device-connection-status', connectionStatus => { + console.warn('device-connection-status', connectionStatus); + const copyConnectionStatus: DeviceConnectionStatusWithOptionalUuid = { + ...connectionStatus, + }; + delete copyConnectionStatus.uuid; // So we dont pollute redux store + + dispatch( + bluetoothConnectDeviceEventAction({ + uuid: connectionStatus.uuid, + connectionStatus: copyConnectionStatus, + }), + ); + }); + }, +); diff --git a/packages/suite/src/components/suite/PrerequisitesGuide/DeviceConnect.tsx b/packages/suite/src/components/suite/PrerequisitesGuide/DeviceConnect.tsx index 4707675ac4a..5008d0953e1 100644 --- a/packages/suite/src/components/suite/PrerequisitesGuide/DeviceConnect.tsx +++ b/packages/suite/src/components/suite/PrerequisitesGuide/DeviceConnect.tsx @@ -1,3 +1,5 @@ +import { Button } from '@trezor/components'; + import { Translation, TroubleshootingTips, WebUsbButton } from 'src/components/suite'; import { TROUBLESHOOTING_TIP_BRIDGE_STATUS, @@ -10,9 +12,15 @@ import { interface DeviceConnectProps { isWebUsbTransport: boolean; + isBluetooth: boolean; + onBluetoothClick: () => void; } -export const DeviceConnect = ({ isWebUsbTransport }: DeviceConnectProps) => { +export const DeviceConnect = ({ + isWebUsbTransport, + onBluetoothClick, + isBluetooth, +}: DeviceConnectProps) => { const items = isWebUsbTransport ? [ TROUBLESHOOTING_TIP_UDEV, @@ -32,7 +40,23 @@ export const DeviceConnect = ({ isWebUsbTransport }: DeviceConnectProps) => { } items={items} - cta={isWebUsbTransport ? : undefined} + cta={ + // eslint-disable-next-line no-nested-ternary + isBluetooth ? ( + + ) : isWebUsbTransport ? ( + + ) : undefined + } data-testid="@connect-device-prompt/no-device-detected" /> ); diff --git a/packages/suite/src/components/suite/PrerequisitesGuide/PrerequisitesGuide.tsx b/packages/suite/src/components/suite/PrerequisitesGuide/PrerequisitesGuide.tsx index 509705e0b1e..f344aef0173 100644 --- a/packages/suite/src/components/suite/PrerequisitesGuide/PrerequisitesGuide.tsx +++ b/packages/suite/src/components/suite/PrerequisitesGuide/PrerequisitesGuide.tsx @@ -1,15 +1,19 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import styled from 'styled-components'; import { motion } from 'framer-motion'; import { getStatus, deviceNeedsAttention } from '@suite-common/suite-utils'; -import { Button, motionEasing } from '@trezor/components'; +import { Button, ElevationContext, ElevationDown, Flex, motionEasing } from '@trezor/components'; import { selectDevices, selectDevice } from '@suite-common/wallet-core'; import { ConnectDevicePrompt, Translation } from 'src/components/suite'; import { useDispatch, useSelector } from 'src/hooks/suite'; -import { selectIsWebUsb, selectPrerequisite } from 'src/reducers/suite/suiteReducer'; +import { + selectIsWebUsb, + selectIsBluetooth, + selectPrerequisite, +} from 'src/reducers/suite/suiteReducer'; import { goto } from 'src/actions/suite/routerActions'; import { Transport } from './Transport'; @@ -26,6 +30,7 @@ import { DeviceUpdateRequired } from './DeviceUpdateRequired'; import { DeviceDisconnectRequired } from './DeviceDisconnectRequired'; import { MultiShareBackupInProgress } from './MultiShareBackupInProgress'; import { DeviceUsedElsewhere } from './DeviceUsedElsewhere'; +import { BluetoothConnect } from '../bluetooth/BluetoothConnect'; const Wrapper = styled.div` display: flex; @@ -48,11 +53,15 @@ interface PrerequisitesGuideProps { } export const PrerequisitesGuide = ({ allowSwitchDevice }: PrerequisitesGuideProps) => { + const [isBluetoothConnectOpen, setIsBluetoothConnectOpen] = useState(false); + const dispatch = useDispatch(); + const device = useSelector(selectDevice); const devices = useSelector(selectDevices); const connectedDevicesCount = devices.filter(d => d.connected === true).length; const isWebUsbTransport = useSelector(selectIsWebUsb); + const isBluetooth = useSelector(selectIsBluetooth); const prerequisite = useSelector(selectPrerequisite); const TipComponent = useMemo( @@ -63,7 +72,13 @@ export const PrerequisitesGuide = ({ allowSwitchDevice }: PrerequisitesGuideProp case 'device-disconnect-required': return ; case 'device-disconnected': - return ; + return ( + setIsBluetoothConnectOpen(true)} + /> + ); case 'device-unacquired': return ; case 'device-used-elsewhere': @@ -91,7 +106,7 @@ export const PrerequisitesGuide = ({ allowSwitchDevice }: PrerequisitesGuideProp return <>; } }, - [prerequisite, isWebUsbTransport, device], + [prerequisite, isWebUsbTransport, isBluetooth, device], ); const handleSwitchDeviceClick = () => @@ -99,30 +114,46 @@ export const PrerequisitesGuide = ({ allowSwitchDevice }: PrerequisitesGuideProp return ( - - - {allowSwitchDevice && connectedDevicesCount > 1 && ( - - - - )} + {isBluetoothConnectOpen ? ( + + {/* Here we need to draw the inner card with elevation -1 (custom design) */} + + + setIsBluetoothConnectOpen(false)} + uiMode="spatial" + /> + + + + ) : ( + <> + - - - + {allowSwitchDevice && connectedDevicesCount > 1 && ( + + + + )} + + + + + + )} ); }; diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothConnect.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothConnect.tsx new file mode 100644 index 00000000000..cd586fc664c --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/BluetoothConnect.tsx @@ -0,0 +1,210 @@ +import { useCallback, useEffect, useState } from 'react'; + +import TrezorConnect from '@trezor/connect'; +import { Card, Column, ElevationUp } from '@trezor/components'; +import { spacings } from '@trezor/theme'; +import { notificationsActions } from '@suite-common/toast-notifications'; +import { TimerId } from '@trezor/type-utils'; + +import { BluetoothNotEnabled } from './errors/BluetoothNotEnabled'; +import { BluetoothDeviceList } from './BluetoothDeviceList'; +import { BluetoothVersionNotCompatible } from './errors/BluetoothVersionNotCompatible'; +import { BluetoothTips } from './BluetoothTips'; +import { BluetoothScanHeader } from './BluetoothScanHeader'; +import { BluetoothScanFooter } from './BluetoothScanFooter'; +import { useDispatch, useSelector } from '../../../hooks/suite'; +import { BluetoothSelectedDevice } from './BluetoothSelectedDevice'; +import { + bluetoothConnectDeviceEventAction, + bluetoothScanStatusAction, +} from '../../../actions/bluetooth/bluetoothActions'; +import { + selectBluetoothDeviceList, + selectBluetoothEnabled, + selectBluetoothScanStatus, +} from '../../../reducers/bluetooth/bluetoothSelectors'; +import { BluetoothPairingPin } from './BluetoothPairingPin'; +import { bluetoothStartScanningThunk } from '../../../actions/bluetooth/bluetoothStartScanningThunk'; +import { bluetoothStopScanningThunk } from '../../../actions/bluetooth/bluetoothStopScanningThunk'; +import { bluetoothConnectDeviceThunk } from '../../../actions/bluetooth/bluetoothConnectDeviceThunk'; + +const SCAN_TIMEOUT = 30_000; + +type BluetoothConnectProps = { + onClose: () => void; + uiMode: 'spatial' | 'card'; +}; + +export const BluetoothConnect = ({ onClose, uiMode }: BluetoothConnectProps) => { + const dispatch = useDispatch(); + const [selectedDeviceUuid, setSelectedDeviceUuid] = useState(null); + const [scannerTimerId, setScannerTimerId] = useState(null); + + const isBluetoothEnabled = useSelector(selectBluetoothEnabled); + const scanStatus = useSelector(selectBluetoothScanStatus); + const deviceList = useSelector(selectBluetoothDeviceList); + const devices = Object.values(deviceList); + + const selectedDevice = + selectedDeviceUuid !== null ? deviceList[selectedDeviceUuid] ?? null : null; + + useEffect(() => { + dispatch(bluetoothStartScanningThunk()); + + return () => { + dispatch(bluetoothStopScanningThunk()); + }; + }, [dispatch]); + + const clearScamTimer = useCallback(() => { + if (scannerTimerId !== null) { + clearTimeout(scannerTimerId); + } + }, [scannerTimerId]); + + useEffect(() => { + // Intentionally no `clearScamTimer`, this is first run and if we use this we would create infinite re-render + const timerId = setTimeout(() => { + dispatch(bluetoothScanStatusAction({ status: 'done' })); + }, SCAN_TIMEOUT); + + setScannerTimerId(timerId); + }, [dispatch]); + + const onReScanClick = () => { + setSelectedDeviceUuid(null); + dispatch(bluetoothScanStatusAction({ status: 'running' })); + + clearScamTimer(); + const timerId = setTimeout(() => { + dispatch(bluetoothScanStatusAction({ status: 'done' })); + }, SCAN_TIMEOUT); + setScannerTimerId(timerId); + }; + + const onSelect = async (uuid: string) => { + setSelectedDeviceUuid(uuid); + + const result = await dispatch(bluetoothConnectDeviceThunk({ uuid })).unwrap(); + + if (!result.success) { + dispatch( + bluetoothConnectDeviceEventAction({ + uuid, + connectionStatus: { type: 'error', error: result.error }, + }), + ); + dispatch( + notificationsActions.addToast({ + type: 'error', + error: result.error, + }), + ); + } else { + // Todo: What to do with error in this flow? UI-Wise + + dispatch( + bluetoothConnectDeviceEventAction({ + uuid, + connectionStatus: { type: 'connected' }, + }), + ); + + // WAIT for connect event, TODO: figure out better way + const closePopupAfterConnection = () => { + TrezorConnect.off('device-connect', closePopupAfterConnection); + TrezorConnect.off('device-connect_unacquired', closePopupAfterConnection); + // setSelectedDeviceStatus({ type: 'error', uuid }); // Todo: what here? + }; + TrezorConnect.on('device-connect', closePopupAfterConnection); + TrezorConnect.on('device-connect_unacquired', closePopupAfterConnection); + } + }; + + if (!isBluetoothEnabled) { + return ; + } + + // Todo: incompatible version + // eslint-disable-next-line no-constant-condition + if (false) { + return ; + } + + console.log('selectedDevice', selectedDevice); + + // This is fake, we scan for devices all the time + const isScanning = scanStatus !== 'done'; + const scanFailed = devices.length === 0 && scanStatus === 'done'; + + const handlePairingCancel = () => { + setSelectedDeviceUuid(null); + onReScanClick(); + }; + + if ( + selectedDevice !== null && + selectedDevice.status.type === 'pairing' && + (selectedDevice.status.pin?.length ?? 0) > 0 + ) { + return ( + + ); + } + + if (selectedDevice !== null) { + return ; + } + + const content = scanFailed ? ( + + ) : ( + + ); + + return ( + + + + + + {/* Here we need to do +1 in elevation because of custom design on the welcome screen */} + {uiMode === 'spatial' ? {content} : content} + + {uiMode === 'card' && ( + + )} + + + + {uiMode === 'spatial' && ( + // Here we need to do +2 in elevation because of custom design on the welcome screen + + + + + + )} + + ); +}; diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothDevice.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothDevice.tsx new file mode 100644 index 00000000000..2c3e243ba4d --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/BluetoothDevice.tsx @@ -0,0 +1,54 @@ +import { Column, FlexProps, Icon, Row, Text } from '@trezor/components'; +import { RotateDeviceImage } from '@trezor/product-components'; +import { spacings } from '@trezor/theme'; +import { BluetoothDevice as BluetoothDeviceType } from '@trezor/transport-bluetooth'; +import { models } from '@trezor/connect/src/data/models'; // Todo: solve this import issue +import { DeviceModelInternal } from '@trezor/connect'; + +type BluetoothDeviceProps = { + device: BluetoothDeviceType; + flex?: FlexProps['flex']; + margin?: FlexProps['margin']; +}; + +// TODO some config map number => DeviceModelInternal +const getModelEnumFromBytesUtil = (_id: number) => { + return DeviceModelInternal.T3W1; +}; + +// TODO some config map number => color id +// discuss final format of it +const getColorEnumFromVariantBytesUtil = (variant: number) => { + return variant; +}; + +export const BluetoothDevice = ({ device, flex, margin }: BluetoothDeviceProps) => { + const model = getModelEnumFromBytesUtil(device.data[2]); + const color = getColorEnumFromVariantBytesUtil(device.data[1]); + const colorName = models[model].colors[color.toString()]; + + return ( + + + + + Trezor Safe 7 + + + + {colorName} + + + + {device.name} + + + + + ); +}; diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothDeviceItem.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothDeviceItem.tsx new file mode 100644 index 00000000000..776e08683f2 --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/BluetoothDeviceItem.tsx @@ -0,0 +1,25 @@ +import { BluetoothDevice as BluetoothDeviceType } from '@trezor/transport-bluetooth'; +import { Button, Row } from '@trezor/components'; +import { spacings } from '@trezor/theme'; + +import { BluetoothDevice } from './BluetoothDevice'; + +type BluetoothDeviceItemProps = { + device: BluetoothDeviceType; + onClick: () => void; + isDisabled?: boolean; +}; + +export const BluetoothDeviceItem = ({ device, onClick, isDisabled }: BluetoothDeviceItemProps) => ( + + + + +); diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothDeviceList.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothDeviceList.tsx new file mode 100644 index 00000000000..38ac7fd159d --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/BluetoothDeviceList.tsx @@ -0,0 +1,42 @@ +import { Card, Column, SkeletonRectangle, Row } from '@trezor/components'; +import { spacings } from '@trezor/theme'; + +import { BluetoothDeviceItem } from './BluetoothDeviceItem'; +import { BluetoothDeviceState } from '../../../reducers/bluetooth/bluetoothReducer'; + +type BluetoothDeviceListProps = { + deviceList: BluetoothDeviceState[]; + onSelect: (uuid: string) => void; + isScanning: boolean; + isDisabled: boolean; +}; + +const SkeletonDevice = () => ( + + + + + + + + +); + +export const BluetoothDeviceList = ({ + onSelect, + deviceList, + isScanning, +}: BluetoothDeviceListProps) => ( + + + {deviceList.map(d => ( + onSelect(d.device.uuid)} + /> + ))} + {isScanning && } + + +); diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothPairingPin.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothPairingPin.tsx new file mode 100644 index 00000000000..019d134b8bc --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/BluetoothPairingPin.tsx @@ -0,0 +1,50 @@ +import styled from 'styled-components'; + +import { Row, NewModal, Card, Link, Text } from '@trezor/components'; +import { spacings, spacingsPx, typography } from '@trezor/theme'; +import { BluetoothDevice as BluetoothDeviceType } from '@trezor/transport-bluetooth'; + +import { BluetoothDevice } from './BluetoothDevice'; + +const Pin = styled.div` + display: flex; + flex: 1; + + ${typography.titleLarge} /* Amount */ margin: 0 auto; + + letter-spacing: ${spacingsPx.md}; +`; + +type BluetoothPairingPinProps = { + onCancel: () => void; + pairingPin?: string; + device: BluetoothDeviceType; +}; + +export const BluetoothPairingPin = ({ onCancel, pairingPin, device }: BluetoothPairingPinProps) => ( + + Codes don't match? + + } + > + + + {pairingPin} + + + + +); diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothScanFooter.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothScanFooter.tsx new file mode 100644 index 00000000000..5e333d0938b --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/BluetoothScanFooter.tsx @@ -0,0 +1,31 @@ +import { Text } from '@trezor/components'; +import { spacings } from '@trezor/theme'; + +import { NotTrezorYouAreLookingFor } from './NotTrezorYouAreLookingFor'; +import { BluetoothScanStatus } from '../../../reducers/bluetooth/bluetoothReducer'; + +type BluetoothScanFooterProps = { + onReScanClick: () => void; + scanStatus: BluetoothScanStatus; + numberOfDevices: number; +}; + +export const BluetoothScanFooter = ({ + onReScanClick, + scanStatus, + numberOfDevices, +}: BluetoothScanFooterProps) => { + if (scanStatus === 'running') { + return ( + + Make sure your TS7 is on and in pairing mode (hold power button) + + ); + } + + if (scanStatus === 'done' && numberOfDevices > 0) { + return ; + } + + return null; +}; diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothScanHeader.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothScanHeader.tsx new file mode 100644 index 00000000000..43eb14b8ac6 --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/BluetoothScanHeader.tsx @@ -0,0 +1,35 @@ +import { spacings } from '@trezor/theme'; +import { Button, Row, Spinner, Text } from '@trezor/components'; + +type BluetoothScanHeaderProps = { + isScanning: boolean; + numberOfDevices: number; + onClose: () => void; +}; + +export const BluetoothScanHeader = ({ + isScanning, + onClose, + numberOfDevices, +}: BluetoothScanHeaderProps) => ( + + + {isScanning ? ( + <> + + Scanning + + ) : ( + + {numberOfDevices} Trezors Found + + )} + + + +); diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothSelectedDevice.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothSelectedDevice.tsx new file mode 100644 index 00000000000..e6d82017db3 --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/BluetoothSelectedDevice.tsx @@ -0,0 +1,66 @@ +import { Card, ElevationContext, Icon, Row, Spinner, Text } from '@trezor/components'; +import { spacings } from '@trezor/theme'; + +import { BluetoothDevice } from './BluetoothDevice'; +import { BluetoothDeviceState } from '../../../reducers/bluetooth/bluetoothReducer'; +import { BluetoothTips } from './BluetoothTips'; + +const PairedComponent = () => ( + + {/* Todo: here we shall solve how to continue with Trezor Host Protocol */} + + Paired + +); + +const PairingComponent = () => ( + + + Pairing + +); + +export type OkComponentProps = { + device: BluetoothDeviceState; +}; + +const OkComponent = ({ device }: OkComponentProps) => ( + + + + {device.status.type === 'connected' ? : } + +); + +export type ErrorComponentProps = { + device: BluetoothDeviceState; + onReScanClick: () => void; +}; + +const ErrorComponent = ({ device, onReScanClick }: ErrorComponentProps) => { + if (device.status.type !== 'error') { + return null; + } + + return ; +}; + +export type BluetoothSelectedDeviceProps = { + device: BluetoothDeviceState; + onReScanClick: () => void; +}; + +export const BluetoothSelectedDevice = ({ + device, + onReScanClick, +}: BluetoothSelectedDeviceProps) => ( + + {device.status.type === 'error' ? ( + + ) : ( + + + + )} + +); diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothTips.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothTips.tsx new file mode 100644 index 00000000000..1b08a63b5d8 --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/BluetoothTips.tsx @@ -0,0 +1,58 @@ +import { ReactNode } from 'react'; + +import { Button, Card, Column, Divider, Icon, IconName, Row, Text } from '@trezor/components'; +import { spacings } from '@trezor/theme'; + +type BluetoothTipProps = { + icon: IconName; + header: string; + text: string; +}; + +const BluetoothTip = ({ icon, header, text }: BluetoothTipProps) => ( + + + + {header} + + {text} + + + +); + +type BluetoothTipsProps = { + onReScanClick: () => void; + header: ReactNode; +}; + +export const BluetoothTips = ({ onReScanClick, header }: BluetoothTipsProps) => ( + + + + {header} + + + + + + + + + + +); diff --git a/packages/suite/src/components/suite/bluetooth/NotTrezorYouAreLookingFor.tsx b/packages/suite/src/components/suite/bluetooth/NotTrezorYouAreLookingFor.tsx new file mode 100644 index 00000000000..60c7f09573e --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/NotTrezorYouAreLookingFor.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react'; + +import { CollapsibleBox, Link, Text } from '@trezor/components'; + +import { BluetoothTips } from './BluetoothTips'; + +type NotTrezorYouAreLookingForProps = { + onReScanClick: () => void; +}; + +export const NotTrezorYouAreLookingFor = ({ onReScanClick }: NotTrezorYouAreLookingForProps) => { + const [showTips, setShowTips] = useState(false); + + return ( + setShowTips(true)}> + Not the Trezor you’re looking for? + + } + > + {showTips && ( + + )} + + ); +}; diff --git a/packages/suite/src/components/suite/bluetooth/errors/BluetoothDeniedForSuite.tsx b/packages/suite/src/components/suite/bluetooth/errors/BluetoothDeniedForSuite.tsx new file mode 100644 index 00000000000..b885bbc61bb --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/errors/BluetoothDeniedForSuite.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; + +import { Text, NewModal, Column, Banner } from '@trezor/components'; +import { desktopApi } from '@trezor/suite-desktop-api'; +import { spacings } from '@trezor/theme'; + +type BluetoothDeniedForSuiteProps = { + onCancel: () => void; +}; + +export const BluetoothDeniedForSuite = ({ onCancel }: BluetoothDeniedForSuiteProps) => { + const [hasDeeplinkFailed, setHasDeeplinkFailed] = useState(false); + + const openSettings = async () => { + const opened = await desktopApi.bluetoothOpenSettings(); + + console.log('opened', opened); + + if (!opened.success || !opened.payload) { + setHasDeeplinkFailed(true); + } + }; + + return ( + + Enable bluetooth + + Cancel + + + } + > + + Enable bluetooth on your computer + + Or connect your Trezor via cable. + + {hasDeeplinkFailed && ( + + Cannot open bluetooth settings. Please enable bluetooth manually. + + )} + + + ); +}; diff --git a/packages/suite/src/components/suite/bluetooth/errors/BluetoothNotAllowedForSuite.tsx b/packages/suite/src/components/suite/bluetooth/errors/BluetoothNotAllowedForSuite.tsx new file mode 100644 index 00000000000..591b2b8185b --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/errors/BluetoothNotAllowedForSuite.tsx @@ -0,0 +1,3 @@ +export const BluetoothNotAllowedForSuite = () => { + return <>; +}; diff --git a/packages/suite/src/components/suite/bluetooth/errors/BluetoothNotEnabled.tsx b/packages/suite/src/components/suite/bluetooth/errors/BluetoothNotEnabled.tsx new file mode 100644 index 00000000000..f0467563df8 --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/errors/BluetoothNotEnabled.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; + +import { Text, NewModal, Column, Banner } from '@trezor/components'; +import { desktopApi } from '@trezor/suite-desktop-api'; +import { spacings } from '@trezor/theme'; + +type BluetoothNotEnabledProps = { + onCancel: () => void; +}; + +export const BluetoothNotEnabled = ({ onCancel }: BluetoothNotEnabledProps) => { + const [hasDeeplinkFailed, setHasDeeplinkFailed] = useState(false); + + const openSettings = async () => { + const opened = await desktopApi.bluetoothOpenSettings(); + + if (!opened.success) { + setHasDeeplinkFailed(true); + } + }; + + return ( + + Enable bluetooth + + Cancel + + + } + > + + Enable bluetooth on your computer + + Or connect your Trezor via cable. + + {hasDeeplinkFailed && ( + + Cannot open bluetooth settings. Please enable bluetooth manually. + + )} + + + ); +}; diff --git a/packages/suite/src/components/suite/bluetooth/errors/BluetoothVersionNotCompatible.tsx b/packages/suite/src/components/suite/bluetooth/errors/BluetoothVersionNotCompatible.tsx new file mode 100644 index 00000000000..1c176f682cf --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/errors/BluetoothVersionNotCompatible.tsx @@ -0,0 +1,30 @@ +import { Text, NewModal, Column } from '@trezor/components'; +import { spacings } from '@trezor/theme'; + +type BluetoothVersionNotCompatibleProps = { + onCancel: () => void; +}; + +export const BluetoothVersionNotCompatible = ({ onCancel }: BluetoothVersionNotCompatibleProps) => ( + + + Cancel + + + } + > + + + Your computer’s bluetooth version is not compatible with whatever we’re using copy. + + + Use cable, buy a 5.0+ dongle. + + + +); diff --git a/packages/suite/src/components/suite/bluetooth/types.ts b/packages/suite/src/components/suite/bluetooth/types.ts new file mode 100644 index 00000000000..9c853e80dfb --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/types.ts @@ -0,0 +1,12 @@ +import type { DeviceConnectionStatus } from '@trezor/transport-bluetooth'; + +export type FakeScanStatus = 'running' | 'done'; + +export type DeviceBluetoothStatus = + | DeviceConnectionStatus + | { + uuid: string; + type: 'found' | 'error'; + }; + +export type DeviceBluetoothStatusType = DeviceBluetoothStatus['type']; diff --git a/packages/suite/src/reducers/bluetooth/bluetoothReducer.ts b/packages/suite/src/reducers/bluetooth/bluetoothReducer.ts new file mode 100644 index 00000000000..b294b67af93 --- /dev/null +++ b/packages/suite/src/reducers/bluetooth/bluetoothReducer.ts @@ -0,0 +1,133 @@ +import { createReducer } from '@reduxjs/toolkit'; + +import { BluetoothDevice, DeviceConnectionStatus } from '@trezor/transport-bluetooth'; +import { deviceActions } from '@suite-common/wallet-core'; +import { Without } from '@trezor/type-utils/'; + +import { + bluetoothAdapterEventAction, + bluetoothConnectDeviceEventAction, + bluetoothDeviceListUpdate, + bluetoothScanStatusAction, +} from '../../actions/bluetooth/bluetoothActions'; +import { bluetoothStartScanningThunk } from '../../actions/bluetooth/bluetoothStartScanningThunk'; +import { bluetoothStopScanningThunk } from '../../actions/bluetooth/bluetoothStopScanningThunk'; + +export type BluetoothScanStatus = 'running' | 'done'; + +export type DeviceBluetoothStatus = + | Without // We have UUID in the deviceList map in the state + | { + type: 'error'; + error: string; + } + | { + // This is state when device is fully connected and dashboard is shown to the user + // At this point we can save the device to the list of paired devices for future reconnects + type: 'connect-connected'; // Todo: Find better naming + }; + +export type DeviceBluetoothStatusType = DeviceBluetoothStatus['type']; + +export type BluetoothDeviceState = { + device: BluetoothDevice; + status: DeviceBluetoothStatus; +}; + +type BluetoothState = { + isBluetoothEnabled: boolean; + scanStatus: BluetoothScanStatus; + + // This will be persisted, those are devices we believed that are paired + // (because we already successfully paired them in the Suite) in the Operating System + pairedDevices: BluetoothDevice[]; + + // This list of devices that is union of saved-devices and device that we get from scan + deviceList: Record; +}; + +const initialState: BluetoothState = { + isBluetoothEnabled: true, // To prevent the UI from flickering when the page is loaded + scanStatus: 'running', // To prevent the UI from flickering when the page is loaded + pairedDevices: [], + deviceList: {}, +}; + +export const bluetoothReducer = createReducer(initialState, builder => + builder + .addCase(bluetoothAdapterEventAction, (state, { payload: { isPowered } }) => { + state.isBluetoothEnabled = isPowered; + if (!isPowered) { + state.deviceList = {}; + } + }) + .addCase(bluetoothDeviceListUpdate, (state, { payload: { devices } }) => { + const newList: Record = Object.fromEntries( + state.pairedDevices.map(device => [ + device.uuid, + { + device, + status: { type: 'paired' }, + }, + ]), + ); + + devices.forEach(device => { + newList[device.uuid] = { + device, + status: state.deviceList[device.uuid]?.status ?? { type: 'found' }, + }; + }); + + state.deviceList = newList; + }) + .addCase( + bluetoothConnectDeviceEventAction, + (state, { payload: { uuid, connectionStatus } }) => { + const device = state.deviceList[uuid]; + + if (device !== undefined) { + device.status = connectionStatus; + } + }, + ) + .addCase(bluetoothScanStatusAction, (state, { payload: { status } }) => { + state.scanStatus = status; + }) + .addCase(deviceActions.deviceDisconnect, (state, { payload: { bluetoothProps } }) => { + if (bluetoothProps) { + delete state.deviceList[bluetoothProps.uuid]; + } + }) + .addCase( + deviceActions.connectDevice, + ( + state, + { + payload: { + device: { bluetoothProps }, + }, + }, + ) => { + if (bluetoothProps && bluetoothProps.uuid in state.deviceList) { + const deviceState = state.deviceList[bluetoothProps.uuid]; + deviceState.status = { type: 'connect-connected' }; + + // Once device is fully connected, we save it to the list of paired devices + // so next time user opens suite + const foundPairedDevice = state.pairedDevices.find( + it => it.uuid === bluetoothProps.uuid, + ); + if (foundPairedDevice === undefined) { + state.pairedDevices.push(deviceState.device); + } + } + }, + ) + .addCase(bluetoothStartScanningThunk.fulfilled, state => { + state.scanStatus = 'running'; + }) + .addCase(bluetoothStopScanningThunk.fulfilled, state => { + state.scanStatus = 'done'; + }), +); diff --git a/packages/suite/src/reducers/bluetooth/bluetoothSelectors.ts b/packages/suite/src/reducers/bluetooth/bluetoothSelectors.ts new file mode 100644 index 00000000000..97672c10ebf --- /dev/null +++ b/packages/suite/src/reducers/bluetooth/bluetoothSelectors.ts @@ -0,0 +1,7 @@ +import { AppState } from '../store'; + +export const selectBluetoothEnabled = (state: AppState) => state.bluetooth.isBluetoothEnabled; + +export const selectBluetoothDeviceList = (state: AppState) => state.bluetooth.deviceList; + +export const selectBluetoothScanStatus = (state: AppState) => state.bluetooth.scanStatus; diff --git a/packages/suite/src/reducers/store.ts b/packages/suite/src/reducers/store.ts index 27a652447bb..8e6cd049332 100644 --- a/packages/suite/src/reducers/store.ts +++ b/packages/suite/src/reducers/store.ts @@ -20,6 +20,7 @@ import walletReducers from 'src/reducers/wallet'; import onboardingReducers from 'src/reducers/onboarding'; import recoveryReducers from 'src/reducers/recovery'; import backupReducers from 'src/reducers/backup'; +import { bluetoothReducer } from 'src/reducers/bluetooth/bluetoothReducer'; // toastMiddleware can be used only in suite-desktop and suite-web // it's not included into `@suite-middlewares` index import toastMiddleware from 'src/middlewares/suite/toastMiddleware'; @@ -40,6 +41,7 @@ const rootReducer = combineReducers({ backup: backupReducers, desktop: desktopReducer, tokenDefinitions: tokenDefinitionsReducer, + bluetooth: bluetoothReducer, }); export type AppState = ReturnType; diff --git a/packages/suite/src/reducers/suite/suiteReducer.ts b/packages/suite/src/reducers/suite/suiteReducer.ts index 6ade3ad22ad..adc8f2c3464 100644 --- a/packages/suite/src/reducers/suite/suiteReducer.ts +++ b/packages/suite/src/reducers/suite/suiteReducer.ts @@ -25,7 +25,7 @@ import { isSkippedHashCheckError, revisionCheckErrorScenarios, } from 'src/constants/suite/firmware'; -import { isWebUsb } from 'src/utils/suite/transport'; +import { isWebUsb, isBluetoothTransport } from 'src/utils/suite/transport'; import { RouterRootState, selectRouter } from './routerReducer'; @@ -406,6 +406,12 @@ export const selectIsWebUsb = (state: SuiteRootState) => { return isWebUsb(transport); }; +export const selectIsBluetooth = (state: SuiteRootState) => { + const transport = selectTransport(state); + + return isBluetoothTransport(transport); +}; + export const selectIsActionAbortable = (state: SuiteRootState) => { const transport = selectTransport(state); diff --git a/packages/suite/src/types/suite/index.ts b/packages/suite/src/types/suite/index.ts index b8a8cc4e233..d8f413cf26c 100644 --- a/packages/suite/src/types/suite/index.ts +++ b/packages/suite/src/types/suite/index.ts @@ -25,6 +25,7 @@ import type { WalletAction } from 'src/types/wallet'; import type { BackupAction } from 'src/actions/backup/backupActions'; import type { RecoveryAction } from 'src/actions/recovery/recoveryActions'; import type { GuideAction } from 'src/actions/suite/guideActions'; +import { allBluetoothActions } from 'src/actions/bluetooth/bluetoothActions'; // reexport export type { ExtendedMessageDescriptor } from 'src/components/suite/Translation'; @@ -58,6 +59,7 @@ type DiscoveryAction = ReturnType<(typeof discoveryActions)[keyof typeof discove type DeviceAuthenticityAction = ReturnType< (typeof deviceAuthenticityActions)[keyof typeof deviceAuthenticityActions] >; +type BluetoothAction = ReturnType<(typeof allBluetoothActions)[keyof typeof allBluetoothActions]>; // all actions from all apps used to properly type Dispatch. export type Action = @@ -83,7 +85,8 @@ export type Action = | ProtocolAction | DiscoveryAction | DeviceAction - | DeviceAuthenticityAction; + | DeviceAuthenticityAction + | BluetoothAction; export type ThunkAction = TAction; @@ -111,6 +114,7 @@ export type ForegroundAppProps = { export type ToastNotificationVariant = 'success' | 'info' | 'warning' | 'error' | 'transparent'; export { TorStatus } from '@trezor/suite-desktop-api/src/enums'; + export interface TorBootstrap { current: number; total: number; diff --git a/packages/suite/src/utils/suite/transport.ts b/packages/suite/src/utils/suite/transport.ts index e0fce593ace..7d29d3781db 100644 --- a/packages/suite/src/utils/suite/transport.ts +++ b/packages/suite/src/utils/suite/transport.ts @@ -2,3 +2,6 @@ import { AppState } from 'src/types/suite'; export const isWebUsb = (transport?: AppState['suite']['transport']) => !!(transport && transport.type && transport.type === 'WebUsbTransport'); + +export const isBluetoothTransport = (transport?: AppState['suite']['transport']) => + !!(transport && transport.type && transport.type === 'BluetoothTransport'); diff --git a/packages/suite/src/views/settings/SettingsDebug/BluetoothEraseBonds.tsx b/packages/suite/src/views/settings/SettingsDebug/BluetoothEraseBonds.tsx new file mode 100644 index 00000000000..46913a520e6 --- /dev/null +++ b/packages/suite/src/views/settings/SettingsDebug/BluetoothEraseBonds.tsx @@ -0,0 +1,39 @@ +import { useState } from 'react'; + +import { Button } from '@trezor/components'; +import TrezorConnect from '@trezor/connect'; + +import { useSelector } from 'src/hooks/suite'; +import { ActionColumn, SectionItem, TextColumn } from 'src/components/suite'; + +export const BluetoothEraseBonds = () => { + const device = useSelector(state => state.device.selectedDevice); + + const [inProgress, setInProgress] = useState(false); + + const onCheckFirmwareAuthenticity = async () => { + setInProgress(true); + // TODO: missing button request in FW + const result = await TrezorConnect.eraseBonds({ device }); + console.warn('Erase bonds!', result); + setInProgress(false); + }; + + return ( + + + + + + + ); +}; diff --git a/packages/suite/src/views/settings/SettingsDebug/SettingsDebug.tsx b/packages/suite/src/views/settings/SettingsDebug/SettingsDebug.tsx index a49284d7641..f5037885d03 100644 --- a/packages/suite/src/views/settings/SettingsDebug/SettingsDebug.tsx +++ b/packages/suite/src/views/settings/SettingsDebug/SettingsDebug.tsx @@ -21,6 +21,7 @@ import { TriggerHighlight } from './TriggerHighlight'; import { Backends } from './Backends'; import { PreField } from './PreField'; import { Tor } from './Tor'; +import { BluetoothEraseBonds } from './BluetoothEraseBonds'; export const SettingsDebug = () => { const flags = useSelector(selectSuiteFlags); @@ -76,6 +77,11 @@ export const SettingsDebug = () => { {JSON.stringify(flags)} + {isDesktop() && ( + + + + )} ); }; diff --git a/packages/suite/src/views/settings/SettingsDebug/Transport.tsx b/packages/suite/src/views/settings/SettingsDebug/Transport.tsx index 145a4b37e2d..025f8b722b6 100644 --- a/packages/suite/src/views/settings/SettingsDebug/Transport.tsx +++ b/packages/suite/src/views/settings/SettingsDebug/Transport.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react'; +import TrezorConnect from '@trezor/connect'; import { Checkbox } from '@trezor/components'; import { isDesktop } from '@trezor/env-utils'; import { ArrayElement } from '@trezor/type-utils'; @@ -80,6 +81,7 @@ export const Transport = () => { : [...debugTransports, transport.name]; dispatch(setDebugMode({ transports: nextTransports })); + TrezorConnect.setTransports({ transports: nextTransports }); }} /> diff --git a/packages/suite/src/views/suite/SwitchDevice/SwitchDevice.tsx b/packages/suite/src/views/suite/SwitchDevice/SwitchDevice.tsx index 64cc08a0b33..44f0b2f8228 100644 --- a/packages/suite/src/views/suite/SwitchDevice/SwitchDevice.tsx +++ b/packages/suite/src/views/suite/SwitchDevice/SwitchDevice.tsx @@ -1,6 +1,8 @@ +import { useState } from 'react'; + import * as deviceUtils from '@suite-common/suite-utils'; import { selectDevice, selectDevices } from '@suite-common/wallet-core'; -import { Column } from '@trezor/components'; +import { Button, Column, Icon, Row, Text } from '@trezor/components'; import { spacings } from '@trezor/theme'; import { ForegroundAppProps } from 'src/types/suite'; @@ -8,8 +10,11 @@ import { useSelector } from 'src/hooks/suite'; import { DeviceItem } from './DeviceItem/DeviceItem'; import { SwitchDeviceModal } from './SwitchDeviceModal'; +import { BluetoothConnect } from '../../../components/suite/bluetooth/BluetoothConnect'; export const SwitchDevice = ({ onCancel }: ForegroundAppProps) => { + const [isBluetoothMode, setIsBluetoothMode] = useState(false); + const selectedDevice = useSelector(selectDevice); const devices = useSelector(selectDevices); @@ -26,17 +31,27 @@ export const SwitchDevice = ({ onCancel }: ForegroundAppProps) => { return ( - - {sortedDevices.map((device, index) => ( - - ))} - + {isBluetoothMode ? ( + setIsBluetoothMode(false)} uiMode="card" /> + ) : ( + + {sortedDevices.map((device, index) => ( + + ))} + + + )} ); }; diff --git a/packages/suite/tsconfig.json b/packages/suite/tsconfig.json index 9d9fe32a368..8db7c614d9e 100644 --- a/packages/suite/tsconfig.json +++ b/packages/suite/tsconfig.json @@ -106,6 +106,7 @@ { "path": "../suite-desktop-api" }, { "path": "../suite-storage" }, { "path": "../theme" }, + { "path": "../transport-bluetooth" }, { "path": "../type-utils" }, { "path": "../urls" }, { "path": "../utils" }, diff --git a/packages/transport-bluetooth/.dockerignore b/packages/transport-bluetooth/.dockerignore new file mode 100644 index 00000000000..6b18d4e394e --- /dev/null +++ b/packages/transport-bluetooth/.dockerignore @@ -0,0 +1,3 @@ +libDev +target +shell.nix diff --git a/packages/transport-bluetooth/.gitignore b/packages/transport-bluetooth/.gitignore new file mode 100644 index 00000000000..fa8d85ac52f --- /dev/null +++ b/packages/transport-bluetooth/.gitignore @@ -0,0 +1,2 @@ +Cargo.lock +target diff --git a/packages/transport-bluetooth/Cargo.toml b/packages/transport-bluetooth/Cargo.toml new file mode 100644 index 00000000000..20209e0414a --- /dev/null +++ b/packages/transport-bluetooth/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "trezor-ble" +version = "0.1.1" +edition = "2021" + +[dependencies] +# btleplug = { path="../../../btleplug", version = "0.11.5", features = ["serde"] } +btleplug = { git = "https://github.com/deviceplug/btleplug.git", branch = "dev", features = ["serde"] } +dashmap = "5.5.3" +futures = "0.3.30" +log = "0.4.8" +pretty_env_logger = "0.5.0" +tokio = { version = "1.35.1", features = ["sync", "rt", "macros", "rt-multi-thread", "io-util"] } +tokio-tungstenite = "0.21.0" +futures-util = { version = "0.3.28", default-features = false, features = ["sink", "std"] } +uuid = "1.6.1" +serde = {version = "1.0.215", features = ["derive"] } +serde_json = "1.0.132" +structopt = "0.3.15" + + +[target.'cfg(target_os = "linux")'.dependencies] +dbus = {version = "0.9.7", features = ["vendored"]} +dbus-tokio = "0.7.6" +bluez-async = "0.7.2" +# bluer ={ version = "0.17.3", features = ["full"] } + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.57.0", features = ["Devices_Bluetooth", "Devices_Enumeration"] } + diff --git a/packages/transport-bluetooth/README.md b/packages/transport-bluetooth/README.md new file mode 100644 index 00000000000..8500b3e13a2 --- /dev/null +++ b/packages/transport-bluetooth/README.md @@ -0,0 +1,33 @@ +# @trezor/transport-bluetooth + +### Server development + +Prerequisites: [RUST](https://www.rust-lang.org/tools/install) + +### Vscode: + +Vscode rust-analyzer extensions: + +- install `rust-analyzer` plugin +- (NixOS only) install `nix-env-selector` plugin and [follow readme](https://marketplace.visualstudio.com/items?itemName=arrterian.nix-env-selector) to setup + +Vscode `.vscode/settings`: + +``` +"rust-analyzer.cargo.sysroot": "discover", +"rust-analyzer.diagnostics.disabled": ["unresolved-proc-macro"] +"rust-analyzer.linkedProjects": ["./packages/transport-bluetooth/Cargo.toml"] +(NixOS only) "nixEnvSelector.nixFile": "${workspaceFolder}/packages/transport-bluetooth/shell.nix", +``` + +### NixOS: + +``` +nix-shell ./packages/transport-bluetooth/shell.nix +``` + +### Run server: + +``` +yarn workspace @trezor/transport-bluetooth server:dev +``` diff --git a/packages/transport-bluetooth/bin/linux/trezor-ble b/packages/transport-bluetooth/bin/linux/trezor-ble new file mode 100755 index 00000000000..32f3de5e758 Binary files /dev/null and b/packages/transport-bluetooth/bin/linux/trezor-ble differ diff --git a/packages/transport-bluetooth/bin/macos/trezor-ble b/packages/transport-bluetooth/bin/macos/trezor-ble new file mode 100755 index 00000000000..3ccf21c7ea4 Binary files /dev/null and b/packages/transport-bluetooth/bin/macos/trezor-ble differ diff --git a/packages/transport-bluetooth/bin/windows/trezor-ble.exe b/packages/transport-bluetooth/bin/windows/trezor-ble.exe new file mode 100755 index 00000000000..a8f094a4d82 Binary files /dev/null and b/packages/transport-bluetooth/bin/windows/trezor-ble.exe differ diff --git a/packages/transport-bluetooth/docker/Dockerfile-linux b/packages/transport-bluetooth/docker/Dockerfile-linux new file mode 100644 index 00000000000..b22396bf5f8 --- /dev/null +++ b/packages/transport-bluetooth/docker/Dockerfile-linux @@ -0,0 +1,20 @@ +# FROM rust:1.59.0 +FROM rust:latest + +RUN apt update && apt upgrade -y +RUN apt install -y \ + libdbus-1-dev \ + pkg-config \ + g++-mingw-w64-x86-64 \ + musl-dev \ + musl-tools + +# RUN cargo build --target x86_64-unknown-linux-musl + +RUN rustup target add x86_64-unknown-linux-musl + +WORKDIR /app + +CMD /bin/bash + +# cargo build --target x86_64-unknown-linux-musl --release diff --git a/packages/transport-bluetooth/docker/Dockerfile-macos b/packages/transport-bluetooth/docker/Dockerfile-macos new file mode 100644 index 00000000000..967e03131f0 --- /dev/null +++ b/packages/transport-bluetooth/docker/Dockerfile-macos @@ -0,0 +1,10 @@ +# https://github.com/joseluisq/rust-linux-darwin-builder/blob/master/README.md +# or https://github.com/autozimu/docker-rust-cross-for-macos/blob/master/Dockerfile + +FROM joseluisq/rust-linux-darwin-builder:1.75.0 + +WORKDIR /app + +# cargo build --target x86_64-apple-darwin --release + +CMD /bin/bash diff --git a/packages/transport-bluetooth/docker/Dockerfile-windows b/packages/transport-bluetooth/docker/Dockerfile-windows new file mode 100644 index 00000000000..6e12a808ba2 --- /dev/null +++ b/packages/transport-bluetooth/docker/Dockerfile-windows @@ -0,0 +1,15 @@ +# https://www.docker.com/blog/cross-compiling-rust-code-for-multiple-architectures/ + +FROM rust:latest + +RUN apt update && apt upgrade -y +RUN apt install -y g++-mingw-w64-x86-64 + +RUN rustup target add x86_64-pc-windows-gnu +RUN rustup toolchain install stable-x86_64-pc-windows-gnu + +WORKDIR /app + +# cargo build --target x86_64-pc-windows-gnu --release + +CMD ["cargo", "build", "--target", "x86_64-pc-windows-gnu", "--release"] diff --git a/packages/transport-bluetooth/package.json b/packages/transport-bluetooth/package.json new file mode 100644 index 00000000000..681ecafcf86 --- /dev/null +++ b/packages/transport-bluetooth/package.json @@ -0,0 +1,43 @@ +{ + "name": "@trezor/transport-bluetooth", + "version": "1.0.0", + "private": true, + "description": "Rust websocket server for communication via bluetooth", + "license": "SEE LICENSE IN LICENSE.md", + "repository": { + "type": "git", + "url": "git://github.com/trezor/trezor-suite.git" + }, + "bugs": { + "url": "https://github.com/trezor/trezor-suite/issues" + }, + "keywords": [ + "Trezor", + "transport" + ], + "main": "./src/index", + "browser": "./src/renderer.ts", + "scripts": { + "lint:js": "yarn g:eslint '**/*.{ts,tsx,js}'", + "type-check": "yarn g:tsc --build", + "test:unit": "jest", + "lint:rs": "cargo fmt --all -- --check", + "server:dev": "RUST_BACKTRACE=1 && RUST_LOG=debug && cargo run", + "server:build": "RUST_BACKTRACE=1 && RUST_LOG=debug && cargo run", + "ui:dev": "webpack serve --config ./webpack/webpack.dev.js", + "ui:build": "webpack --config ./webpack/webpack.build.js" + }, + "dependencies": { + "@trezor/ipc-proxy": "workspace:*", + "@trezor/protocol": "workspace:*", + "@trezor/transport": "workspace:*", + "@trezor/utils": "workspace:*", + "@trezor/websocket-client": "workspace:*", + "ws": "^8.18.0" + }, + "devDependencies": { + "webpack": "^5.96.1", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^5.0.4" + } +} diff --git a/packages/transport-bluetooth/shell.nix b/packages/transport-bluetooth/shell.nix new file mode 100644 index 00000000000..a74b4e26329 --- /dev/null +++ b/packages/transport-bluetooth/shell.nix @@ -0,0 +1,27 @@ +# pinned to nixos-24.05 on commit https://github.com/NixOS/nixpkgs/commit/c6ce5bd4ab657df958ebd6f38723f81c5546a661 +with import + (builtins.fetchTarball { + url = "https://github.com/NixOS/nixpkgs/archive/c6ce5bd4ab657df958ebd6f38723f81c5546a661.tar.gz"; + sha256 = "0i5z7b087kr2hnkgs17d36c54arjbgwlwyxw1ibh03d1k1xfcyf2"; + }) +{ }; + +stdenv.mkDerivation { + name = "trezor-ble-dev"; + nativeBuildInputs = [ + rustc + rustfmt + # rustup + cargo + cargo-cross + pkg-config + ]; + + buildInputs = [ + openssl + dbus + ]; + + RUST_BACKTRACE = 1; + RUST_LOG = "debug"; +} diff --git a/packages/transport-bluetooth/src/client/bluetooth-api.ts b/packages/transport-bluetooth/src/client/bluetooth-api.ts new file mode 100644 index 00000000000..67c577bc92b --- /dev/null +++ b/packages/transport-bluetooth/src/client/bluetooth-api.ts @@ -0,0 +1,135 @@ +import { + AbstractApi, + AbstractApiAwaitedResult, + DEVICE_TYPE, +} from '@trezor/transport/src/api/abstract'; +import { PathInternal } from '@trezor/transport/src/types'; +import { createDeferred, Deferred } from '@trezor/utils'; + +import { TrezorBle } from './trezor-ble'; +import { BluetoothDevice } from './types'; + +// Reflection of @trezor/transport/src/api + +export class BluetoothApi extends AbstractApi { + chunkSize = 244; + api = new TrezorBle({}); + readDataBuffer: Record = {}; // TODO: Record + readRequests: Record> = {}; + recentChunk: Record = {}; + + private devicesToDescriptors(devices: BluetoothDevice[]) { + return devices + .filter(d => d.connected && d.paired) + .map(d => ({ + path: d.uuid as string as PathInternal, + type: DEVICE_TYPE.TypeBluetooth, + uuid: d.uuid, + })); + } + + async init() { + const { api } = this; + await api.connect(); + + const transportApiEvent = ({ devices }: { devices: BluetoothDevice[] }) => { + this.emit('transport-interface-change', this.devicesToDescriptors(devices)); + }; + // api.on('device_discovered', transportApiEvent); // TODO: auto-reconnect + api.on('device_connected', transportApiEvent); + api.on('device_disconnected', () => transportApiEvent({ devices: [] })); // TODO: this.devices + api.on('device_read', ({ uuid, data }) => { + console.warn('DeviceRead handled in BT api', data); + if (this.readRequests[uuid]) { + // message received AFTER read request, resolve pending response + this.readRequests[uuid].resolve(data); + delete this.readRequests[uuid]; + } else { + // message received BEFORE read request, put chunk into buffer and wait for read request + this.readDataBuffer[uuid]?.push(data); + } + }); + + api.on('adapter_state_changed', ({ powered }) => { + if (!powered) { + transportApiEvent({ devices: [] }); + } + }); + + // TODO: sanc and use DeviceDiscovered only if there are known devices + // await api.send('start_scan'); + + return this.success(true); + } + + enumerate() { + return Promise.resolve(this.success(this.devicesToDescriptors(this.api.getDevices()))); + } + + listen() { + console.warn('BluetoothApi listen method not implemented.'); + } + + dispose() { + this.api.disconnect(); + console.warn('BluetoothApi dispose method not implemented.'); + } + + public read(path: string, _signal?: AbortSignal) { + return new Promise>(resolve => { + // TODO: chunk duplicates will be resolved by protocol-v2 (thp) + const bufferMessage = this.readDataBuffer[path]?.shift() || []; + if (bufferMessage.length > 0) { + const prevMessage = this.recentChunk[path] || []; + const isTheSame = Buffer.compare( + Buffer.from(bufferMessage), + Buffer.from(prevMessage), + ); + if (isTheSame === 0) { + console.warn('--> is the same!!!!', bufferMessage, this.readDataBuffer); + + return new Promise(resolve => setTimeout(resolve, 500)).then(() => + this.read(path), + ); + } + + this.recentChunk[path] = bufferMessage; + + return resolve(this.success(Buffer.from(bufferMessage))); + } + + this.readRequests[path] = createDeferred(); + + return this.readRequests[path].promise.then(message => { + delete this.readRequests[path]; + this.recentChunk[path] = message; + resolve(this.success(Buffer.from(message))); + }); + }); + } + + async write(path: string, buffer: Buffer) { + console.warn('Device write', buffer); + const result = await this.api.send('write', [path, Array.from(buffer)]); + console.warn('Device write', result); + + return this.success(undefined); + } + + async openDevice(path: string) { + this.readDataBuffer[path] = []; + const result = await this.api.send('open_device', path); + console.warn('Device opened', result); + + return this.success(undefined); + } + + async closeDevice(path: string) { + delete this.readDataBuffer[path]; + delete this.recentChunk[path]; + const result = await this.api.send('close_device', path); + console.warn('Device closed', result); + + return this.success(undefined); + } +} diff --git a/packages/transport-bluetooth/src/client/bluetooth-ipc-proxy.ts b/packages/transport-bluetooth/src/client/bluetooth-ipc-proxy.ts new file mode 100644 index 00000000000..30e1f500c72 --- /dev/null +++ b/packages/transport-bluetooth/src/client/bluetooth-ipc-proxy.ts @@ -0,0 +1,243 @@ +import { TypedEmitter } from '@trezor/utils'; + +import { TrezorBle } from './trezor-ble'; +import type { + TrezorBleSettings, + BluetoothIpcApi, + BluetoothApiEvents, + BluetoothDevice, + DeviceConnectionStatus, +} from './types'; + +const notImplemented = (): any => { + throw new Error('Method not implemented.'); +}; + +// override this object with proxy +export const bluetoothManager: BluetoothIpcApi = { + getAvailability: notImplemented, + connectDevice: notImplemented, + disconnectDevice: notImplemented, + forgetDevice: notImplemented, + startScan: notImplemented, + stopScan: notImplemented, + on: notImplemented, + off: notImplemented, + removeAllListeners: notImplemented, +}; + +// class implemented in electron main context +// should be overriden in electron renderer context +export class BluetoothApiImpl extends TypedEmitter implements BluetoothIpcApi { + private api: TrezorBle; + + constructor(settings: TrezorBleSettings) { + super(); + this.api = new TrezorBle(settings); + } + + getAvailability() { + // throw new Error('Method not implemented.'); + return Promise.resolve({ success: true, payload: true } as const); + } + + disconnectDevice() { + // throw new Error('Method not implemented.'); + return Promise.resolve({ success: true, payload: true } as const); + } + + async connectDevice(uuid: string) { + // try { + // await this.api.connect(); + // } catch (error) { + // return { success: false, error: error.message }; + // } + + const emitStatus = (event: DeviceConnectionStatus) => { + this.emit('device-connection-status', event); + }; + + // Todo: enter pairing mode + const UUID = 'hci0/dev_E1_43_47_BA_6A_69'; + // const linuxPin = { uuid: UUID, type: 'pairing' as const, pin: '' }; + const windowsPin = { + uuid: UUID, + type: 'pairing' as const, + pin: '123456', + }; + + // UI: + // - click button -> "Connecting" + // - "pair-device-event" -> pairing -> show PIN Modal + // - (Win/Linux) "pair-device-event" -> paired -> hide PIN Modal + // - "connect-device-event":start -> connecting -> hide PIN Modal (for Mac) + // - "connect-device-event" :done -> connected, but ...connect-connecting again + + // 1. [Win, Lin, Mac] In case of windows, this is where we get PIN, on Mac we may not get this + await new Promise(resolve => setTimeout(resolve, 2000)); + emitStatus(windowsPin); + + // 2. [Win, Lin] Simulates that user confirmed PIN on the device + await new Promise(resolve => setTimeout(resolve, 4000)); + emitStatus({ + uuid: UUID, + type: 'paired', + }); + + // 3. [Win, Lin, Mac] Simulates that device is starting to connect + await new Promise(resolve => setTimeout(resolve, 2000)); + emitStatus({ + uuid: UUID, + type: 'connecting', + }); + + // 4. [Win, Lin, Mac] Simulates that device is starting to connect + await new Promise(resolve => setTimeout(resolve, 2000)); + emitStatus({ + uuid: UUID, + type: 'connected', + }); + + return { success: true } as const; + + // this.api.on('device_connection_status', event => + // emitStatus({ uuid: event.uuid, type: event.phase }), + // ); + // this.api.on('device_pairing', event => { + // if (!event.paired) { + // emitStatus({ + // uuid, + // type: 'pairing', + // pin: event.pin, + // }); + // } else { + // emitStatus({ + // uuid: event.uuid, + // type: 'paired', + // }); + // } + // }); + // + // try { + // const result = await this.api.send('connect_device', uuid); + // console.warn('Connect result', result); + // } catch (error) { + // return { success: false, error: error.message }; + // } + // + // return { success: true } as const; + } + + async forgetDevice(id: string): Promise { + try { + await this.api.connect(); + } catch (error) { + return { success: false, error: error.message }; + } + + const result = await this.api + .send('forget_device', id) + .then(() => ({ success: true }) as const) + .catch(error => ({ success: false, error: error.message })); + console.warn('Forget result', result); + } + + async startScan() { + try { + await this.api.connect(); + } catch (error) { + return { success: false, error: error.message }; + } + + const connectableDevices = (devs: BluetoothDevice[]) => { + return devs + .filter(d => d.paired || (d.data && d.data[0] === 1)) + .map(d => { + // TODO: paired device on linux adv. data missing + if (d.paired && d.data.length === 0) { + d.data.push(1, 1, 1); + } + + return d; + }); + // return devs.filter(d => d.data.length > 0 && (d.paired || d.data[0] === 1)); + }; + const emitSelect = ({ devices }: { devices: BluetoothDevice[] }) => { + this.emit('device-list-update', [ + { + name: 'TrezorZephyr', + internal_model: 1, + model_variant: 3, + uuid: 'hci0/dev_E1_43_47_BA_6A_69', + connected: false, + timestamp: 1732787043, + rssi: 0, + pairing_mode: false, + paired: true, + data: [ + // 1. "pairing_mode" - prvni byte jesli device vubec je pairovatelny (a ma se vubec zobrazit v ui) + 1, + + // 2. "model_variant" - jeden byte, vlastne nemusi to byt jen color, nekdy v budocnosti treba tam muze byt i nejaka jina specificka vlastnost, + // typu, ma NFC, nema NFC. neni to zatim nikde zdefiniovany mapa musi teprv vzniknout vajemnou domluvou, trezor zatim posila zahardcodovanou + // value, udelal jsem na to nejakou TODO utilitku v jedne z komponent + 1, + + // 3. "internal_model" - jeden byte, bude nejak namapovany cislo na enum DeviceIntrenalModel v trezor/connect. + // opet neni nikde zdefiniovany, trezor posila zahardcodovany, a je na to TODO utilitka v te componente + 3, + ], + }, + ...devices, + ]); + + // this.emit('device-list-update', connectableDevices(devices)); + }; + const emitAdapterState = ({ powered }: { powered: boolean }) => { + this.emit('adapter-event', powered); + if (!powered) { + // api.send('stop_scan'); + } else { + this.api.send('start_scan').catch(error => { + console.warn('Start scan error', error); + }); + } + }; + + this.api.on('device_discovered', emitSelect); + this.api.on('device_updated', emitSelect); + this.api.on('device_connected', emitSelect); + this.api.on('device_disconnected', emitSelect); + this.api.on('adapter_state_changed', emitAdapterState); + + try { + const info = await this.api.send('get_info'); + // emit adapter event + if (!info.powered) { + this.emit('adapter-event', false); + } + + const devices = await this.api.send('start_scan'); + emitSelect({ devices: connectableDevices(devices) }); + } catch (error) { + return { success: false, error: error.message }; + } + + return { success: true } as const; + } + + async stopScan() { + try { + await this.api.connect(); + await this.api.send('stop_scan'); + + return { success: true } as const; + } catch (error) { + return { success: false, error: error.message }; + } finally { + console.warn('FINALLY!!!'); + this.api.removeAllListeners(); + this.api.disconnect(); + } + } +} diff --git a/packages/transport-bluetooth/src/client/transport.ts b/packages/transport-bluetooth/src/client/transport.ts new file mode 100644 index 00000000000..30e3daf129c --- /dev/null +++ b/packages/transport-bluetooth/src/client/transport.ts @@ -0,0 +1,32 @@ +import { Transport as AbstractTransport, AbstractApiTransport } from '@trezor/transport'; + +import { BluetoothApi } from './bluetooth-api'; + +// Reflection of @trezor/transport/src/transports +export class BluetoothTransport extends AbstractApiTransport { + public name = 'BluetoothTransport' as const; + public apiType = 'bluetooth' as const; + private wsApi: BluetoothApi; + + constructor(params: ConstructorParameters[0]) { + const { logger, ...rest } = params; + + const api = new BluetoothApi({ logger }); + + super({ + api, + logger, + ...rest, + }); + + this.wsApi = api; + } + + public init({ signal }: { signal?: AbortSignal } = {}) { + return this.scheduleAction(async () => { + await this.wsApi.init(); + + return super.init({ signal }); + }); + } +} diff --git a/packages/transport-bluetooth/src/client/trezor-ble.ts b/packages/transport-bluetooth/src/client/trezor-ble.ts new file mode 100644 index 00000000000..d30020e6751 --- /dev/null +++ b/packages/transport-bluetooth/src/client/trezor-ble.ts @@ -0,0 +1,124 @@ +import { WebsocketClient } from '@trezor/websocket-client'; + +import { + TrezorBleSettings, + NotificationEvent, + BluetoothDevice, + BluetoothInfo, + Logger, +} from './types'; + +// Client for trezor-ble websocket server +export class TrezorBle extends WebsocketClient { + readonly settings: TrezorBleSettings; + readonly logger: Logger; + private devices: BluetoothDevice[] = []; + + constructor(settings: TrezorBleSettings) { + super({ url: 'a' }); + this.settings = Object.freeze(settings); + this.logger = settings.logger || { + debug: (..._args: string[]) => {}, + log: (..._args: string[]) => {}, + warn: (..._args: string[]) => {}, + error: (..._args: string[]) => {}, + }; + } + + createWebsocket() { + return this.initWebsocket({ + url: 'ws://127.0.0.1:21327', + // headers: { + // Origin: 'https://node.trezor.io', + // 'User-Agent': 'Trezor Suite', + // }, + }); + } + + ping() { + // return Promise.resolve(); + return this.sendMessage({ method: { name: 'ping', args: [] } }); + } + + // private init() { + // const { ws } = this; + // if (!ws || !this.isConnected()) { + // throw Error('Websocket init cannot be called'); + // } + + // // remove previous listeners and add new listeners + // ws.removeAllListeners(); + // ws.on('error', this.onError.bind(this)); + // // ws.on('message', this.onMessage.bind(this)); + // const om = this.onMessage.bind(this); + // // ws.onmessage = function (evt) { + // // om(evt as any); + // // }; + // ws.on('message', evt => om(evt as any)); + // ws.on('close', () => { + // this.onClose(); + // this.emit('api_disconnected'); + // }); + + // const transportApiEvent = ({ devices }: { devices: BluetoothDevice[] }) => { + // this.devices = devices.sort((a, b) => a.timestamp - b.timestamp); + // }; + + // this.on('device_connected', transportApiEvent); + // this.on('device_disconnected', transportApiEvent); + // } + + public getDevices() { + return this.devices; + } + + send(method: 'get_info', adapter?: boolean): Promise; + send(method: 'enumerate'): Promise; + send(method: 'start_scan'): Promise; + send(method: 'stop_scan'): Promise; + send(method: 'connect_device', uuid: string): Promise; + send(method: 'disconnect_device', uuid: string): Promise; + send(method: 'forget_device', uuid: string): Promise; + send(method: 'open_device', uuid: string): Promise; + send(method: 'close_device', uuid: string): Promise; + send(method: 'read', uuid: string): Promise; + send(method: 'write', args: [string, number[]]): Promise; + // public send(method: string, ...args: any[]) { + public send(method: string, args?: any) { + // const { ws } = this; + // if (!ws) throw new Error('websocket_not_initialized'); + + // const { promiseId, promise } = this.messages.create(); + // const req = { + // id: promiseId, + // // method: { name: method, args: [...args] }, + // method: { name: method, args: args || [] }, + // }; + // ws.send(JSON.stringify(req)); + + return this.sendMessage({ method: { name: method, args: args || [] } }); + } + + protected onMessage(message: string | Buffer) { + // Websocket.Data + try { + const resp = JSON.parse(message.toString()); + if (resp.event) { + this.emit(resp.event, resp.payload); + + return; + } + + const id = Number(resp.id); // TODO + const messageSettled = resp.error + ? this.messages.reject(id, new Error(resp.error)) + : this.messages.resolve(id, resp.payload); + + if (!messageSettled) { + console.warn('not settled?', resp); + } + } catch { + // empty + } + } +} diff --git a/packages/transport-bluetooth/src/client/types.ts b/packages/transport-bluetooth/src/client/types.ts new file mode 100644 index 00000000000..f36d478ce50 --- /dev/null +++ b/packages/transport-bluetooth/src/client/types.ts @@ -0,0 +1,76 @@ +import type { TypedEmitter } from '@trezor/utils'; + +export interface Logger { + debug(...args: any): void; + log(...args: any): void; + warn(...args: any): void; + error(...args: any): void; +} + +export interface TrezorBleSettings { + logger?: Logger; + timeout?: number; +} + +export type BluetoothInfo = { + powered: boolean; + api_version: string; + adapter_info: string; + adapter_version: number; +}; + +// see: ./src/server/device.rs impl serde::Serialize for TrezorDevice +export interface BluetoothDevice { + name: string; + internal_model: number; + model_variant: number; + uuid: string; + connected: boolean; + timestamp: number; + rssi: number; + pairing_mode: boolean; + paired: boolean; + data: number[]; +} + +export interface NotificationEvent { + adapter_state_changed: { powered: boolean }; + device_discovered: { uuid: string; devices: BluetoothDevice[] }; + device_updated: { uuid: string; devices: BluetoothDevice[] }; + device_connected: { uuid: string; devices: BluetoothDevice[] }; + device_pairing: { uuid: string; paired: boolean; pin?: string }; + device_connection_status: { uuid: string; phase: 'connecting' | 'connected' }; + device_disconnected: { uuid: string; devices: BluetoothDevice[] }; + device_read: { uuid: string; data: number[] }; +} + +export type DeviceConnectionStatus = { uuid: string } & ( + | { type: 'pairing'; pin?: string } + | { type: 'paired' } + | { type: 'connecting' } + | { type: 'connected' } +); + +type Success

= P extends unknown ? { success: true } : { success: true; payload: P }; +type Failure = { success: false; error: string }; +type ApiResponse

= Success

| Failure; + +export interface BluetoothApiEvents { + 'adapter-event': boolean; + 'device-list-update': BluetoothDevice[]; + 'device-connection-status': DeviceConnectionStatus; +} + +type TypedManagerEvents = TypedEmitter; + +export interface BluetoothIpcApi { + getAvailability(): Promise>; + startScan(): Promise; + stopScan(): Promise; + connectDevice(id: string): Promise; + disconnectDevice(id: string): Promise; + forgetDevice(id: string): Promise; + on: TypedManagerEvents['on']; + off: TypedManagerEvents['off']; + removeAllListeners: TypedManagerEvents['removeAllListeners']; +} diff --git a/packages/transport-bluetooth/src/index.ts b/packages/transport-bluetooth/src/index.ts new file mode 100644 index 00000000000..ca19fb4c52c --- /dev/null +++ b/packages/transport-bluetooth/src/index.ts @@ -0,0 +1,4 @@ +export { TrezorBle } from './client/trezor-ble'; +export { BluetoothTransport } from './client/transport'; +export { bluetoothManager, BluetoothApiImpl } from './client/bluetooth-ipc-proxy'; +export * from './client/types'; diff --git a/packages/transport-bluetooth/src/main.rs b/packages/transport-bluetooth/src/main.rs new file mode 100644 index 00000000000..a2c78003ea9 --- /dev/null +++ b/packages/transport-bluetooth/src/main.rs @@ -0,0 +1,26 @@ +use pretty_env_logger; +use structopt::StructOpt; + +mod server; + +#[derive(StructOpt, Debug)] +#[structopt(name = "trezor-ble")] +struct Opts { + // optional port + #[structopt(short, long, default_value = "21327")] + port: u16, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + pretty_env_logger::init(); + + let opt = Opts::from_args(); + let addr = vec!["127.0.0.1:".to_string(), opt.port.to_string()].join(""); + + if let Err(err) = server::start(&addr).await { + eprintln!("Websocket server start error: {err:?}"); + } + + Ok(()) +} diff --git a/packages/transport-bluetooth/src/renderer.ts b/packages/transport-bluetooth/src/renderer.ts new file mode 100644 index 00000000000..7b1e4bf836c --- /dev/null +++ b/packages/transport-bluetooth/src/renderer.ts @@ -0,0 +1,28 @@ +import { createIpcProxy } from '@trezor/ipc-proxy'; + +import type { BluetoothIpcApi } from './client/types'; +import { bluetoothManager } from './client/bluetooth-ipc-proxy'; + +// **browser context** +// file exported as index of `@trezor/transport-bluetooth` +// create ipcProxy and wrap each method of bluetoothManager + +const proxyState = () => { + let proxyPromise: Promise | undefined; + + return () => { + if (proxyPromise) return proxyPromise; + + proxyPromise = createIpcProxy('Bluetooth'); + + return proxyPromise; + }; +}; + +const getProxy = proxyState(); +(Object.keys(bluetoothManager) as (keyof BluetoothIpcApi)[]).forEach(key => { + (bluetoothManager[key] as unknown) = (...args: any[]) => + getProxy().then(p => (p[key] as any)(...args)); +}); + +export { bluetoothManager }; diff --git a/packages/transport-bluetooth/src/server/adapter_manager.rs b/packages/transport-bluetooth/src/server/adapter_manager.rs new file mode 100644 index 00000000000..c01569d426e --- /dev/null +++ b/packages/transport-bluetooth/src/server/adapter_manager.rs @@ -0,0 +1,401 @@ +use btleplug::api::{Central, CentralEvent, CentralState}; +use btleplug::platform::{Adapter, Manager}; +use dashmap::DashMap; +use futures::StreamExt; +use log::info; +use std::fmt::{self, Debug, Formatter}; +use std::sync::Arc; +use tokio::sync::{broadcast, Mutex}; +use tokio::task::JoinHandle; +use tokio::time::{sleep, Duration}; + +use crate::server::device::TrezorDevice; +use crate::server::types::{ChannelMessage, NotificationEvent}; +use crate::server::utils; + +#[derive(Clone)] +pub struct AdapterManager { + pub manager: Manager, + pub adapter: Arc>>, + adapter_watcher: Arc>, + pub is_scanning: bool, + pub last_update: u64, // timestamp of any recent change + peripherals: Arc>>, +} + +impl Debug for AdapterManager { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "AdapterManager") + } +} + +struct AdapterWatcher { + stream: Option>, + watcher: Option>, + scanning_update: Option>, + listeners: Vec>, +} + +impl AdapterManager { + pub async fn new() -> Result> { + let is_scanning = false; + let peripherals = Arc::new(Mutex::new(DashMap::new())); + let last_update = 0; + let manager = Manager::new().await.expect("Failed to initialize Manager"); + let adapter = Arc::new(Mutex::new(None)); + let adapter_watcher = Arc::new(Mutex::new(AdapterWatcher { + watcher: None, + stream: None, + scanning_update: None, + listeners: Vec::new(), + })); + + Ok(Self { + manager, + adapter, + adapter_watcher, + is_scanning, + peripherals, + last_update, + }) + } + + // let manager = Manager::new().await.expect("BLEManager error"); + // - windows and mac (?) create new stateless instance of the Adapter every time manager.adapters() is called + // - linux keep reference(s) for once initialized adapters + // - linux throws error if adapter is disabled + + // system specific behavior. if CentralState is poweredOff + // - windows and macos always returns the Adapter object + // - linux (bluez) returns nothing or throws error + // once the Adapter is found and assigned then it correctly reports it's state change (on/off) + // start thread and wait until Adapter is enabled/found. + + async fn dispatch_adapter_event(&self) { + // let adapter = utils::get_adapter(&self.manager, None).await; + // if adapter.is_some() { + // let mut val = self.adapter.lock().await; + // *val = adapter; + // let adapter = val.clone(); + // drop(val); + + // let powered = adapter.unwrap().adapter_state().await.unwrap() == CentralState::PoweredOn; + // self.send_to_listeners(ChannelMessage::Notification( + // NotificationEvent::AdapterStateChanged { powered }, + // )).await; + + // info!("Adapter found"); + // if let Err(e) = self.start_events_stream().await { + // println!("Failed to start_events_stream: {:?}", e); + // } + // } + + // Ok(adapter) + + let current = self.adapter.lock().await; + let adapter = current.clone(); + + if adapter.is_some() { + let powered = + adapter.unwrap().adapter_state().await.unwrap() == CentralState::PoweredOn; + self.send_to_listeners(ChannelMessage::Notification( + NotificationEvent::AdapterStateChanged { powered }, + )) + .await; + } + } + + pub async fn get_adapter(&self) -> Result, Box> { + let current = self.adapter.lock().await; + let adapter_found = current.clone(); + drop(current); + + if adapter_found.is_some() { + return Ok(adapter_found); + } + + let adapter = utils::get_adapter(&self.manager, None).await; + if adapter.is_some() { + let mut val = self.adapter.lock().await; + *val = adapter; + let adapter_found = val.clone(); + drop(val); + + self.dispatch_adapter_event().await; + + info!("Adapter found"); + if let Err(e) = self.start_events_stream().await { + println!("Failed to start_events_stream: {:?}", e); + } + + return Ok(adapter_found); + } + + self.adapter_loader().await; + + Ok(None) + } + + pub async fn watch_adapter(&self, listener: broadcast::Sender) { + let mut state = self.adapter_watcher.lock().await; + + state.listeners.push(listener.clone()); + } + + pub async fn enumerate(&self) -> Vec { + // TODO: enumerate adapter, remove local diff + return self.get_devices().await; + } + + pub async fn adapter_loader(&self) { + let mut state = self.adapter_watcher.lock().await; + // If there's already a watcher, return early + if state.watcher.is_some() { + info!("Adapter loader already running"); + return (); + } + + // self.start_scan().await; + + info!("Adapter loader start"); + let adapter_mutex = self.adapter.clone(); + let manager = self.manager.clone(); + let self_clone = self.clone(); + let watcher = tokio::spawn(async move { + loop { + let adapter = adapter_mutex.lock().await; + let adapter_found = adapter.is_some(); + drop(adapter); // unlock + + if adapter_found { + info!("Adapter found"); + if let Err(e) = self_clone.start_events_stream().await { + println!("Failed to start_events_stream: {:?}", e); + } + // TODO clear watcher + // state.watcher = None; + + break; + } + + info!("Waiting for Adapter"); + sleep(Duration::from_secs(2)).await; + + let adapter = utils::get_adapter(&manager, None).await; + if adapter.is_some() { + info!("Adapter found in adapter_loader"); + let mut val = adapter_mutex.lock().await; + *val = adapter; + drop(val); // unlock + + self_clone.dispatch_adapter_event().await; + } + } + + info!("Adapter loader end"); + }); + state.watcher = Some(watcher); + } + + pub async fn send_to_listeners(&self, message: ChannelMessage) { + info!("send_to_listeners: {:?}", message.clone()); + let state = self.adapter_watcher.lock().await; + let listeners = state.listeners.clone(); + for listener in &listeners { + if let Err(e) = listener.send(message.clone()) { + println!("Failed to send message: {:?}", e); + } + } + } + + async fn add_device(&self, uuid: String, device: TrezorDevice) { + let peripherals = self.peripherals.lock().await; + peripherals.insert(uuid, device); + } + + pub async fn get_device(&self, uuid: String) -> Option { + let peripherals = self.peripherals.lock().await; + if let Some(device) = peripherals.get(&uuid) { + return Some(device.clone()); + } + None + } + + pub async fn get_devices(&self) -> Vec { + let peripherals = self.peripherals.lock().await; + let mut devices: Vec = peripherals + .iter() + .map(|entry| entry.value().clone()) + .collect(); + devices.sort_by(|a, b| a.get_timestamp().cmp(&b.get_timestamp())); + + return devices; + } + + async fn start_events_stream(&self) -> Result<(), Box> { + let adapter = self.adapter.lock().await; + if adapter.is_none() { + return Err("Adapter not found")?; + } + + let adp = adapter.as_ref().unwrap(); + // platform specific, on linux this will start scanning + let mut events = adp.events().await?; + + // subscribe to broadcast channel + // let mut receiver = sender.subscribe(); + + let adapter = adp.clone(); + let self_clone = self.clone(); + let _stream_task = tokio::spawn(async move { + while let Some(event) = events.next().await { + match event { + CentralEvent::StateUpdate(state) => { + info!("StateUpdate: {:?}", state); + let mut powered = false; + if state == CentralState::PoweredOn { + powered = true; + } + self_clone + .send_to_listeners(ChannelMessage::Notification( + NotificationEvent::AdapterStateChanged { powered }, + )) + .await; + } + CentralEvent::DeviceDiscovered(id) => { + let evt = utils::scan_filter(&adapter, &id).await; + if evt.is_some() { + let device = adapter.peripheral(&id).await.expect("REASON"); + let dev = TrezorDevice::new(device.clone()).await.unwrap(); + self_clone.add_device(id.to_string(), dev.clone()).await; + let devices = self_clone.get_devices().await; + + let uuid = id.to_string(); + self_clone + .send_to_listeners(ChannelMessage::Notification( + NotificationEvent::DeviceDiscovered { + uuid, + timestamp: 0, + devices, + }, + )) + .await; + } + } + CentralEvent::DeviceUpdated(id) => { + let device = self_clone.get_device(id.to_string()).await; + if device.is_some() { + let peripheral = + adapter.peripheral(&id).await.expect("Peripheral not found"); + let mut device = device.unwrap(); + let mut emit_update = false; + if let Ok(updated) = device.update_properties(peripheral).await { + emit_update = updated; + } + + if emit_update { + let uuid = id.to_string(); + let devices = self_clone.get_devices().await; + self_clone + .send_to_listeners(ChannelMessage::Notification( + NotificationEvent::DeviceUpdated { + uuid, + devices, + }, + )) + .await; + } + } + } + CentralEvent::ServicesAdvertisement { id, services: _ } => { + let device = self_clone.get_device(id.to_string()).await; + if device.is_some() { + // info!("ServicesAdvertisement: {:?}", services); + } + } + CentralEvent::ServiceDataAdvertisement { id, service_data: _ } => { + let device = self_clone.get_device(id.to_string()).await; + if device.is_some() { + // info!("ServiceDataAdvertisement: {:?} {:?}", id, service_data); + } + } + CentralEvent::ManufacturerDataAdvertisement { + id, + manufacturer_data: _, + } => { + let device = self_clone.get_device(id.to_string()).await; + if device.is_some() { + // info!("ManufacturerDataAdvertisement: {:?} {:?}", id, manufacturer_data); + } + } + CentralEvent::DeviceDisconnected(id) => { + if let Some(device) = self_clone.get_device(id.to_string()).await { + info!("DeviceDisconnected: {:?} : {:?}", id, device); + + // TODO: make util from this + let peripheral = match adapter.peripheral(&id).await { + Ok(peripheral) => Some(peripheral), + Err(_error) => None, + }; + let _ = device.update_connection(peripheral).await; + + let devices = self_clone.get_devices().await; + self_clone + .send_to_listeners(ChannelMessage::Notification( + NotificationEvent::DeviceDisconnected { + uuid: id.to_string(), + devices, + }, + )) + .await; + } + } + // CentralEvent::DeviceConnected fires up too early, connected doesn't mean that pairing process is completed. + // this event is emitted after successfully connection/subscription process by connect_device method. + CentralEvent::DeviceConnected(id) => { + info!("DeviceConnected: {:?}", id); + } + } + } + }); + + Ok(()) + } + + pub async fn stop_watching(&self, listener: &broadcast::Sender) { + let mut state = self.adapter_watcher.lock().await; + state + .listeners + .retain(|item| item.same_channel(listener) == false); + + if state.listeners.is_empty() { + if let Some(watcher) = state.watcher.take() { + info!("Adapter watcher stopping"); + watcher.abort(); + // state.watcher = None; + } + } + } + + pub async fn start_scan(&self) { + let mut state = self.adapter_watcher.lock().await; + + let self_clone = self.clone(); + let update_thread = tokio::spawn(async move { + loop { + let _devices = self_clone.get_devices().await; + // self_clone.send_to_listeners( + // ChannelMessage::Notification( + // NotificationEvent::ScanningUpdate { devices }, + // ) + // ).await; + + sleep(Duration::from_secs(3)).await; + } + }); + + state.scanning_update = Some(update_thread); + } + + pub async fn stop_scan(&self) {} +} diff --git a/packages/transport-bluetooth/src/server/connection_handler.rs b/packages/transport-bluetooth/src/server/connection_handler.rs new file mode 100644 index 00000000000..2496900d952 --- /dev/null +++ b/packages/transport-bluetooth/src/server/connection_handler.rs @@ -0,0 +1,106 @@ +use futures::{SinkExt, StreamExt}; +use log::info; +use std::sync::Arc; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::broadcast; +use tokio::sync::Mutex; +use tokio_tungstenite::tungstenite::Result; +use tokio_tungstenite::{ + accept_async, + tungstenite::{Message, Result as TResult}, +}; + +use crate::server::adapter_manager::AdapterManager; +use crate::server::handle_message; +use crate::server::types::{AbortProcess, ChannelMessage}; +use crate::server::utils; + +async fn handle_connection( + stream: TcpStream, + manager: AdapterManager, +) -> TResult<()> { + let peer = stream + .peer_addr() + .expect("connected streams should have a peer address"); + let ws_stream = accept_async(stream) + .await + .expect("Error during the websocket handshake occurred"); + info!("New WebSocket connection: {}", peer); + let (ws_write, mut ws_read) = ws_stream.split(); + let (sender, mut receiver) = broadcast::channel::(32); + + manager.watch_adapter(sender.clone()).await; + + // create websocket stream mutex to be shared between two threads + let ws_write = Arc::new(Mutex::new(ws_write)); + + // start thread and listen for ChannelMessages emitted by current connection processes + let write_event = ws_write.clone(); + let channel_message_listener = tokio::spawn(async move { + while let Ok(event) = receiver.recv().await { + match event { + ChannelMessage::Response(event) => { + let response = serde_json::to_string(&event).unwrap(); + let mut write_remote = write_event.clone().lock_owned().await; + if let Err(_) = write_remote.send(Message::Text(response)).await {} + } + ChannelMessage::Notification(event) => { + info!("Sending notification {peer:?} {:?}", event); + let response = serde_json::to_string(&event).unwrap(); + let mut write_remote = write_event.clone().lock_owned().await; + if let Err(_) = write_remote.send(Message::Text(response)).await {} + } + _ => {} + } + } + }); + + // in current thread keep listening for incoming websocket messages + let write_response = ws_write.clone(); + while let Some(msg) = ws_read.next().await { + // TODO: panic here computer sleep? + let request = msg.unwrap_or(Message::Text("Unknown request".to_string())); + let response = handle_message(request.clone(), manager.clone(), sender.clone()).await; + + match response { + Some(response) => { + let mut write_remote = write_response.clone().lock_owned().await; + write_remote.send(response).await?; + drop(write_remote); + } + None => { + info!("No response for the request {:?}", request); + } + } + } + + // peer disconnected + manager.stop_watching(&sender).await; + channel_message_listener.abort(); + + if let Err(err) = sender.send(ChannelMessage::Abort(AbortProcess::Disconnect)) { + info!("---> Closing connection error {}", err); + } + + info!("---> Closing connection..."); + + Ok(()) +} + +pub async fn start(address: &str) -> Result<()> { + let tcp_listener = TcpListener::bind(&address).await.expect("Failed to bind"); + info!("Version: {} Listening on: {}", utils::APP_VERSION, address); + + let manager = AdapterManager::new() + .await + .expect("Failed to initialize Manager"); + + while let Ok((stream, _)) = tcp_listener.accept().await { + tokio::spawn(handle_connection( + stream, + manager.clone(), + )); + } + + Ok(()) +} diff --git a/packages/transport-bluetooth/src/server/device.rs b/packages/transport-bluetooth/src/server/device.rs new file mode 100644 index 00000000000..9b22d2e15ce --- /dev/null +++ b/packages/transport-bluetooth/src/server/device.rs @@ -0,0 +1,184 @@ +use btleplug::api::{Peripheral as _, PeripheralProperties}; +use btleplug::platform::Peripheral; +use serde::ser::SerializeStruct; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::{uuid, Uuid}; + +#[derive(Clone, Debug)] +pub struct TrezorDevice { + paired: Arc>, + pairing_mode: bool, + name: String, + data: Arc>>, + internal_model: u8, + model_variant: u8, + uuid: String, + connected: Arc>, + timestamp: Arc>, + rssi: Arc>, // signal strength, 0: weak, -100: strong +} + +impl serde::Serialize for TrezorDevice { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut s = serializer.serialize_struct("TrezorDevice", 3)?; + s.serialize_field("connected", &self.connected.lock().unwrap().clone())?; + s.serialize_field("paired", &self.paired.lock().unwrap().clone())?; + s.serialize_field("pairing_mode", &self.pairing_mode.clone())?; + s.serialize_field("name", &self.name.to_string())?; + s.serialize_field("data", &self.data.lock().unwrap().to_vec())?; + s.serialize_field("internal_model", &self.internal_model.clone())?; + s.serialize_field("model_variant", &self.model_variant.clone())?; + s.serialize_field("uuid", &self.uuid.to_string())?; + s.serialize_field("timestamp", &self.timestamp.lock().unwrap().clone())?; + s.serialize_field("rssi", &self.rssi.lock().unwrap().clone())?; + s.end() + } +} + +fn get_timestamp() -> u64 { + return SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); +} + +#[cfg(target_os = "windows")] +async fn is_paired(peripheral: &Peripheral) -> Result> { + use windows::Devices::Bluetooth::BluetoothLEDevice; + + let address = btleplug::api::BDAddr::from_str_delim(&peripheral.id().to_string()).unwrap(); + let device = BluetoothLEDevice::FromBluetoothAddressAsync(address.into())?.await?; + let device_info = device.DeviceInformation()?; + let pairing = device_info.Pairing()?; + let paired = pairing.IsPaired()?; + + Ok(paired) +} + +#[cfg(target_os = "linux")] +async fn is_paired(peripheral: &Peripheral) -> Result> { + use dbus::arg::{RefArg, Variant}; + use std::collections::HashMap; + use tokio::time::Duration; + + let conn = dbus::blocking::Connection::new_system()?; + let device_path = format!("/org/bluez/{}", peripheral.id().to_string()); + let device_proxy = conn.with_proxy("org.bluez", device_path, Duration::from_secs(10)); + let (props,): (HashMap>>,) = device_proxy.method_call( + "org.freedesktop.DBus.Properties", + "GetAll", + ("org.bluez.Device1",), + )?; + + if let Some(variant) = props.get("Paired") { + if let Some(is_paired) = variant.0.as_any().downcast_ref::().cloned() { + return Ok(is_paired); + } + } + + Ok(false) +} + +#[cfg(target_os = "macos")] +async fn is_paired(peripheral: &Peripheral) -> Result> { + Ok(false) +} + +const MANUFACTURER_DATA: u16 = 65535; +pub const SERVICE_UUID: Uuid = uuid!("8c000001-a59b-4d58-a9ad-073df69fa1b1"); +pub const CHARACTERISTIC_RX: Uuid = uuid!("8c000002-a59b-4d58-a9ad-073df69fa1b1"); +// pub const CHARACTERISTIC_TX: Uuid = uuid!("8c000003-a59b-4d58-a9ad-073df69fa1b1"); + +impl TrezorDevice { + pub async fn new(peripheral: Peripheral) -> Result> { + let PeripheralProperties { + local_name, + manufacturer_data, + rssi, + .. + } = &peripheral.properties().await?.unwrap(); + let connected = &peripheral.is_connected().await.unwrap_or(false); + + let name = local_name.as_ref().unwrap(); + let uuid = &peripheral.id(); + let data = manufacturer_data + .get(&MANUFACTURER_DATA) + .unwrap_or(&vec![]) + .clone(); + println!( + "TrezorDevice initial data {:?}, {:?}", + data, manufacturer_data + ); + let ppp: u8 = 1; + let pairing_mode = data.get(0).unwrap_or(&0) == &ppp; + let model_variant = data.get(1).unwrap_or(&0); + let internal_model = data.get(2).unwrap_or(&0); + let timestamp = get_timestamp(); + let rssi = rssi.unwrap_or(0); + let paired = is_paired(&peripheral).await.unwrap_or(false); + + Ok(Self { + name: name.to_string(), + data: Arc::new(Mutex::new(data.to_vec())), + internal_model: internal_model.clone(), + model_variant: model_variant.clone(), + uuid: uuid.to_string(), + connected: Arc::new(Mutex::new(*connected)), + timestamp: Arc::new(Mutex::new(timestamp)), + rssi: Arc::new(Mutex::new(rssi)), + pairing_mode, + paired: Arc::new(Mutex::new(paired)), + }) + } + + pub async fn update_properties( + &mut self, + peripheral: Peripheral, + ) -> Result> { + if let Ok(properties) = peripheral.properties().await { + let props = properties.unwrap(); + let mut timestamp = self.timestamp.lock().unwrap(); + *timestamp = get_timestamp(); + + let mut rssi = self.rssi.lock().unwrap(); + *rssi = props.rssi.unwrap_or(0); + + if let Some(new_data) = props.manufacturer_data.get(&MANUFACTURER_DATA) { + let mut data = self.data.lock().unwrap(); + if data.len() != new_data.len() { + *data = new_data.clone(); + return Ok(true); + } + } + } + + Ok(false) + } + + // update connection/paired state + pub async fn update_connection(&self, peripheral: Option) { + let mut is_connected = false; + if peripheral.is_some() { + is_connected = peripheral.unwrap().is_connected().await.unwrap_or(false); + if is_connected { + let mut paired = self.paired.lock().unwrap(); + *paired = true; // TODO: only on macos? others take it from is_paired() + } + } + + let mut connected = self.connected.lock().unwrap(); + *connected = is_connected; + } + + pub fn is_paired(&self) -> bool { + return self.paired.lock().unwrap().clone(); + } + + pub fn get_timestamp(&self) -> u64 { + return self.timestamp.lock().unwrap().clone(); + } +} diff --git a/packages/transport-bluetooth/src/server/message_handler.rs b/packages/transport-bluetooth/src/server/message_handler.rs new file mode 100644 index 00000000000..18c89654e4d --- /dev/null +++ b/packages/transport-bluetooth/src/server/message_handler.rs @@ -0,0 +1,78 @@ +use log::info; +use tokio::sync::broadcast::Sender; +use tokio_tungstenite::tungstenite::Message; + +use crate::server::types::{ + ChannelMessage, WsError, WsRequest, WsRequestMethod, WsResponse, +}; + +use crate::server::adapter_manager::AdapterManager; +use crate::server::methods; + +pub async fn handle_message( + msg: Message, + manager: AdapterManager, + sender: Sender, +) -> Option { + if !msg.is_text() { + // ping is sent by the browser WebSocket when host is suspended + if msg.to_string() == "PING" { + return Some(Message::Text("PONG".to_string())); + } + return None; + } + + info!("handle_message Received message from: {}", msg.to_string()); + + // if let Err(request) = serde_json::from_str::(&msg.to_string()) { + let json = serde_json::from_str::(&msg.to_string()); + if json.is_err() { + info!("Serde json error: {:?}", json); + return None; + } + let request = json.unwrap(); + + info!("Method: {:?}", request); + + let payload = match request.method.clone() { + WsRequestMethod::StartScan() => methods::start_scan(manager, sender).await, + WsRequestMethod::StopScan() => methods::stop_scan(manager, sender).await, + WsRequestMethod::GetInfo() => methods::get_info(manager).await, + WsRequestMethod::Enumerate() => methods::enumerate(manager, sender).await, + WsRequestMethod::ConnectDevice(uuid) => methods::connect_device(uuid, manager).await, + WsRequestMethod::DisconnectDevice(uuid) => { + methods::disconnect_device(uuid, manager, sender).await + } + WsRequestMethod::OpenDevice(uuid) => methods::open_device(uuid, manager, sender).await, + WsRequestMethod::CloseDevice(uuid) => methods::close_device(uuid, manager, sender).await, + WsRequestMethod::Read(uuid) => methods::read(uuid, manager, sender).await, + WsRequestMethod::Write(uuid, data) => methods::write(uuid, data, manager, sender).await, + WsRequestMethod::ForgetDevice(uuid) => methods::forget_device(uuid, manager, sender).await, + }; + + match payload { + Ok(payload) => { + info!("Process response ok {:?}", payload); + // let json = serde_json::to_string(&payload); + let json = serde_json::to_string(&WsResponse { + id: request.id.to_string(), + method: request.method, + payload: payload, + }); + if json.is_err() { + return None; + } + return Some(Message::Text(json.unwrap())); + } + Err(err) => { + info!("Process response error {}", err); + let json = serde_json::to_string(&WsError { + id: request.id.to_string(), + method: request.method, + error: err.to_string(), + }); + + return Some(Message::Text(json.unwrap())); + } + } +} diff --git a/packages/transport-bluetooth/src/server/methods/close_device.rs b/packages/transport-bluetooth/src/server/methods/close_device.rs new file mode 100644 index 00000000000..582256a38bd --- /dev/null +++ b/packages/transport-bluetooth/src/server/methods/close_device.rs @@ -0,0 +1,14 @@ +use tokio::sync::broadcast::Sender; + +use crate::server::adapter_manager::AdapterManager; +use crate::server::types::{AbortProcess, ChannelMessage, MethodResult, WsResponsePayload}; + +pub async fn close_device( + _uuid: String, + _manager: AdapterManager, + sender: Sender, +) -> MethodResult { + let _ = sender.send(ChannelMessage::Abort(AbortProcess::Read)); + + Ok(WsResponsePayload::Success(true)) +} diff --git a/packages/transport-bluetooth/src/server/methods/connect_device.rs b/packages/transport-bluetooth/src/server/methods/connect_device.rs new file mode 100644 index 00000000000..2870ffe9102 --- /dev/null +++ b/packages/transport-bluetooth/src/server/methods/connect_device.rs @@ -0,0 +1,471 @@ +use log::info; +use tokio::time::Duration; + +use btleplug::api::{Central, CharPropFlags, Peripheral as _}; +use btleplug::platform::Adapter; + +use crate::server::adapter_manager::AdapterManager; +use crate::server::types::{ChannelMessage, MethodResult, NotificationEvent, WsResponsePayload}; +use crate::server::utils; + +const PAIRING_TIMEOUT: Duration = Duration::from_secs(30); + +#[cfg(target_os = "linux")] +async fn connect_device_inner( + uuid: String, + adapter: Option, + manager: AdapterManager, +) -> MethodResult { + use dbus::arg::{RefArg, Variant}; + use dbus::nonblock::Proxy; + use std::collections::HashMap; + + let dev = manager.get_device(uuid.clone()).await; + if dev.is_none() { + Err("Device not found")?; + } + + if dev.unwrap().is_paired() { + info!("Device already paired"); + return connect_device_common(uuid, adapter, manager).await; + } + + manager + .send_to_listeners(ChannelMessage::Notification( + NotificationEvent::DevicePairing { + uuid: uuid.clone(), + paired: false, + pin: "".to_string(), + }, + )) + .await; + + let (resource, conn) = dbus_tokio::connection::new_system_sync()?; + let connection_task = tokio::spawn(resource); + + let device_path = format!("/org/bluez/{}", uuid.clone()); + let device_proxy = Proxy::new( + "org.bluez", + device_path.clone(), + Duration::from_secs(30), + conn.clone(), + ); + + let device_path_task = device_path.clone(); + let mut props_task = Some(tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + // create new proxy on each try + let device_proxy = Proxy::new( + "org.bluez", + device_path_task.clone(), + Duration::from_secs(5), + conn.clone(), + ); + + let result: Result<(HashMap>>,), dbus::Error> = + device_proxy + .method_call( + "org.freedesktop.DBus.Properties", + "GetAll", + ("org.bluez.Device1",), + ) + .await; + + let mut should_disconnect = false; + match result { + Ok((props,)) => { + if let Some(variant) = props.get("Paired") { + if let Some(is_paired) = variant.0.as_any().downcast_ref::().cloned() + { + if is_paired { + should_disconnect = true; + } + } + } + } + Err(error) => { + return Some(error); + } + } + + if should_disconnect { + let _result: Result<(), dbus::Error> = device_proxy + .method_call("org.bluez.Device1", "Disconnect", ()) + .await; + return None; + } + } + })); + + // Pairing occasionally times out even if pairing process was successful + // error: Did not receive a reply. Possible causes include: the remote application did not send a reply... + // workaround: Listen of "Paired" property changes in props_task above + let pairing_task = tokio::spawn(async move { + // NOTE: there is no way to abort method_call + let result: Result<(), dbus::Error> = device_proxy + .method_call("org.bluez.Device1", "Pair", ()) + .await; + match result { + Ok(_) => { + return None; + } + Err(error) => { + return Some(error); + } + } + }); + + tokio::select! { + response = props_task.as_mut().unwrap() => { + connection_task.abort(); + println!("props_task ended with {response:?}"); + if let Some(err) = response.unwrap() { + return Err(err)?; + } + }, + response = pairing_task => { + connection_task.abort(); + props_task.take().unwrap().abort(); + println!("pairing_task ended with {response:?}"); + if let Some(err) = response.unwrap() { + return Err(err)?; + } + }, + }; + + manager + .send_to_listeners(ChannelMessage::Notification( + NotificationEvent::DevicePairing { + uuid: uuid.clone(), + paired: true, + pin: "".to_string(), + }, + )) + .await; + + return connect_device_common(uuid, adapter, manager).await; +} + +#[cfg(target_os = "macos")] +async fn connect_device_inner( + uuid: String, + adapter: Option, + manager: AdapterManager, +) -> MethodResult { + return connect_device_common(uuid, adapter, manager).await; +} + +#[cfg(target_os = "windows")] +async fn connect_device_inner( + uuid: String, + adapter: Option, + manager: AdapterManager, +) -> MethodResult { + use btleplug::api::BDAddr; + use windows::{ + Devices::Bluetooth::BluetoothLEDevice, + Devices::Enumeration::{ + DeviceInformationCustomPairing, DevicePairingKinds, DevicePairingRequestedEventArgs, + DevicePairingResultStatus, + }, + Foundation::TypedEventHandler, + }; + + let address = BDAddr::from_str_delim(&uuid).unwrap(); + let device = BluetoothLEDevice::FromBluetoothAddressAsync(address.into())?.await?; + let device_info = device.DeviceInformation()?; + let pairing = device_info.Pairing()?; + + if !pairing.IsPaired()? { + println!("Device not paired. Attempting to pair..."); + + if !pairing.CanPair()? { + Err("Device cannot be paired")? + } + + let custom_pairing: DeviceInformationCustomPairing = pairing.Custom()?; + let bt_manager = manager.clone(); + let bt_uuid = uuid.clone(); + let (tx, _) = tokio::sync::broadcast::channel::(32); + let pin_sender = tx.clone(); + let mut listener = tx.subscribe(); + let pin_listener = tokio::spawn(async move { + while let Ok(pin) = listener.recv().await { + bt_manager + .send_to_listeners(ChannelMessage::Notification( + NotificationEvent::DevicePairing { + uuid: bt_uuid.clone(), + paired: false, + pin, + }, + )) + .await; + } + }); + + { + let pairing_requested_handler = TypedEventHandler::new( + move |_sender, args: &Option| { + if let Some(args) = args { + let kind = args.PairingKind()?; + if kind == DevicePairingKinds::ConfirmPinMatch { + let pin = args.Pin()?; + println!("Confirming PIN match: {}", pin); + args.Accept()?; // automatically confirm host pin + if let Err(err) = pin_sender.send(pin.to_string()) { + println!("Error sending PIN match: {:?}", err); + } + } + } + Ok(()) + }, + ); + custom_pairing.PairingRequested(&pairing_requested_handler)?; + } + + let pairing_result = custom_pairing + .PairAsync(DevicePairingKinds::ConfirmPinMatch)? + .await?; + pin_listener.abort(); + let pairing_status = pairing_result.Status()?; + if pairing_status == DevicePairingResultStatus::Paired { + println!("Successfully paired with device"); + // similar to linux, disconnect after successful paring process and proceed to connect_device_common + let result = device.Close(); + if let Err(err) = result { + println!("Error while closing device {:?}", err); + } + + // TODO: maybe this event is useless? + manager + .send_to_listeners(ChannelMessage::Notification( + NotificationEvent::DevicePairing { + uuid: uuid.clone(), + paired: true, + pin: "".to_string(), + }, + )) + .await; + } else { + let error = format!("Pairing failed with status: {:?}", pairing_status); + return Err(error)?; + } + } + + return connect_device_common(uuid, adapter, manager).await; +} + +pub async fn connect_device(uuid: String, manager: AdapterManager) -> MethodResult { + let adapter = manager.get_adapter().await?; + if !(utils::is_adapter_powered(adapter.clone()).await) { + return Err("AdapterDisabled")?; + } + + return connect_device_inner(uuid, adapter, manager).await; +} + +async fn connect_device_common( + uuid: String, + adapter: Option, + manager: AdapterManager, +) -> MethodResult { + let adapter = adapter.unwrap(); + let peripheral = utils::get_peripheral_by_address(&adapter, uuid.clone()).await?; + let device_id = peripheral.id(); + let properties = peripheral.properties().await?; + let is_connected = peripheral.is_connected().await.unwrap_or(false); + // let bt_device = utils::get_bluetooth_device(&peripheral).await?; + let bt_device = manager.get_device(uuid.clone()).await.unwrap(); + + println!( + "Connecting before {:?}, {:?}, {:?}, {:?}", + is_connected, + peripheral.characteristics(), + peripheral.services(), + properties + ); + + // linux: + // - if device is paired it will be visible in Adapter.periperials() even before scanning + // - if device is paired device should already have discovered services (more than 1) right after connection + // macos: + // - paired device ... + // windows: + // - paired device ... + + if !is_connected { + println!("Connecting..."); + + manager + .send_to_listeners(ChannelMessage::Notification( + NotificationEvent::DeviceConnectionStatus { + uuid: uuid.clone(), + phase: "connecting".to_string(), + }, + )) + .await; + + // Connect if we aren't already connected. + if let Err(err) = peripheral.connect().await { + // Linux: + // Error connecting to peripheral: Service discovery timed out + // if let Err(err) = device + // .connect_with_timeout(std::time::Duration::from_secs(5)) + // .await + // { + // TODO: linux, le-connection-abort-by-local https://github.com/hbldh/bleak/issues/993 + // le-connection-abort-by-local means that device was never paired (e) and is not in pairing mode + + // TODO: windows ... (i dont remember the error itself, medium not ready?) + eprintln!("Error connecting to peripheral: {}", err); + return Err(Box::new(err)); + } + } + + let properties = peripheral.properties().await?; + println!( + "Connecting after - before discovering service {:?}, {:?}, {:?}, {:?}", + is_connected, + peripheral.characteristics(), + peripheral.services(), + properties + ); + + if let Err(err) = peripheral.discover_services().await { + println!("Err discovering services first time {:?}", err); + return Err(Box::new(err)); + } + + let properties = peripheral.properties().await?; + println!( + "Connecting discovered services {:?}, {:?}, {:?}", + peripheral.characteristics(), + peripheral.services(), + properties + ); + + let notif_device = uuid.clone(); + let notif_manager = manager.clone(); + let pairing_prompt = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(1000)).await; + + notif_manager + .send_to_listeners(ChannelMessage::Notification( + NotificationEvent::DevicePairing { + uuid: notif_device.clone(), + paired: false, + pin: "".to_string(), + }, + )) + .await; + }); + + let subscription_device = peripheral.clone(); + let start = tokio::time::Instant::now(); + let subscription_task = tokio::spawn(async move { + let mut tries = 0; + loop { + let is_connected = subscription_device.is_connected().await.unwrap_or(false); + if !is_connected { + info!("Disconnected, breaking the loop {}", is_connected); + return false; + } + + info!( + "Trying {} to subscribe loop {:?} {}", + tries, + start.elapsed(), + is_connected + ); + + if start.elapsed() > PAIRING_TIMEOUT { + info!("Timeout, breaking the loop {:?}", start.elapsed()); + return false; + } + + let characteristic = subscription_device + .characteristics() + .into_iter() + .find(|c| c.properties.contains(CharPropFlags::NOTIFY)); + if characteristic.is_some() { + let characteristic = characteristic.unwrap(); + if let Err(err) = subscription_device.subscribe(&characteristic).await { + if err.to_string().contains("authentication") { + // if err.to_string().contains("ATT error") { + println!("--cool off"); + // windows: Error { code: HRESULT(0x80650005), message: "The attribute requires authentication before it can be read or written." }" + // https://learn.microsoft.com/en-us/windows/win32/com/com-error-codes-9 + // E_BLUETOOTH_ATT_INSUFFICIENT_AUTHENTICATION 0x80650005 + // tokio::time::sleep(Duration::from_secs(2)).await; + + // TODO: windows btle-plug subscription error does not clear listener, if i try 10 times i will end wind 10 listeners + if let Err(err) = subscription_device.unsubscribe(&characteristic).await { + println!("Err unsubscribing {:?}", err); + } + } else { + info!("end subscription_task loop wit error {err:?}"); + return false; + } + // linux timeout: DbusError(D-Bus error: Operation failed with ATT error: 0x0e (org.bluez.Error.Failed)) + println!("Err subscribing {:?}", err); + } else { + println!("Unsubscribing...."); + // try to unsubscribe + if let Err(err) = subscription_device.unsubscribe(&characteristic).await { + println!("Err unsubscribing {:?}", err); + } + println!("Subscribed, breaking the loop"); + return true; + } + } else { + println!("Notify characteristics not found"); + } + + tokio::time::sleep(Duration::from_secs(1)).await; + + tries += 1; + } + }); + + let result = subscription_task.await.unwrap_or(false); + if !result { + // TODO: get error from result + Err("Connection failed")? + } + let is_connected = peripheral.is_connected().await.unwrap_or(false); + if !is_connected { + Err("Device disconnected")? + } + + manager + .send_to_listeners(ChannelMessage::Notification( + NotificationEvent::DeviceConnectionStatus { + uuid: uuid.clone(), + phase: "connected".to_string(), + }, + )) + .await; + + let dev = adapter.peripheral(&device_id).await.unwrap(); + pairing_prompt.abort(); + println!( + "Successful subscription {}, {}", + peripheral.address(), + dev.address() + ); + + bt_device.update_connection(Some(dev)).await; + + let state = manager.get_devices().await; + manager + .send_to_listeners(ChannelMessage::Notification( + NotificationEvent::DeviceConnected { + uuid: uuid.clone(), + devices: state, + }, + )) + .await; + + Ok(WsResponsePayload::Success(true)) +} diff --git a/packages/transport-bluetooth/src/server/methods/disconnect_device.rs b/packages/transport-bluetooth/src/server/methods/disconnect_device.rs new file mode 100644 index 00000000000..cf333655737 --- /dev/null +++ b/packages/transport-bluetooth/src/server/methods/disconnect_device.rs @@ -0,0 +1,27 @@ +use btleplug::api::Peripheral as _; +use tokio::sync::broadcast::Sender; + +use crate::server::adapter_manager::AdapterManager; +use crate::server::types::{ChannelMessage, MethodResult, WsResponsePayload}; +use crate::server::utils; + +pub async fn disconnect_device( + uuid: String, + manager: AdapterManager, + _sender: Sender, +) -> MethodResult { + println!("Disconnecting {:?}", uuid); + let adapter = manager.get_adapter().await?; + if !(utils::is_adapter_powered(adapter.clone()).await) { + return Err("Adapted disabled")?; + } + + let adapter = adapter.unwrap(); + let device = utils::get_peripheral_by_address(&adapter, uuid).await?; + let is_connected = device.is_connected().await.unwrap_or(false); + if is_connected { + device.disconnect().await?; + } + + Ok(WsResponsePayload::Success(true)) +} diff --git a/packages/transport-bluetooth/src/server/methods/enumerate.rs b/packages/transport-bluetooth/src/server/methods/enumerate.rs new file mode 100644 index 00000000000..b3feb6a5331 --- /dev/null +++ b/packages/transport-bluetooth/src/server/methods/enumerate.rs @@ -0,0 +1,10 @@ +use log::info; +use tokio::sync::broadcast::Sender; + +use crate::server::adapter_manager::AdapterManager; +use crate::server::types::{ChannelMessage, MethodResult, WsResponsePayload}; + +pub async fn enumerate(_manager: AdapterManager, _sender: Sender) -> MethodResult { + info!("enumerate not implemented!"); + Ok(WsResponsePayload::Data("[]".to_string())) +} diff --git a/packages/transport-bluetooth/src/server/methods/forget_device.rs b/packages/transport-bluetooth/src/server/methods/forget_device.rs new file mode 100644 index 00000000000..525b2434de5 --- /dev/null +++ b/packages/transport-bluetooth/src/server/methods/forget_device.rs @@ -0,0 +1,53 @@ +use log::info; +use tokio::sync::broadcast::Sender; + +use crate::server::adapter_manager::AdapterManager; +use crate::server::types::{ChannelMessage, MethodResult, WsResponsePayload}; + +#[cfg(target_os = "linux")] +pub async fn forget_device( + uuid: String, + _manager: AdapterManager, + _sender: Sender, +) -> MethodResult { + use tokio::time::Duration; + + info!("forget_device tryout on linux"); + + let connection = dbus::blocking::Connection::new_system()?; + let adapter_path = "/org/bluez/hci0"; + let device_path = format!("/org/bluez/{}", uuid.clone()); + let adapter_proxy = connection.with_proxy("org.bluez", adapter_path, Duration::from_secs(5)); + adapter_proxy.method_call("org.bluez.Adapter1", "RemoveDevice", (device_path,))?; + + Ok(WsResponsePayload::Success(true)) +} + +#[cfg(target_os = "windows")] +pub async fn forget_device( + uuid: String, + _manager: AdapterManager, + _sender: Sender, +) -> MethodResult { + use btleplug::api::BDAddr; + use windows::Devices::Bluetooth::BluetoothLEDevice; + + info!("forget_device windows"); + let address = BDAddr::from_str_delim(&uuid).unwrap(); + let device = BluetoothLEDevice::FromBluetoothAddressAsync(address.into())?.await?; + let device_info = device.DeviceInformation()?; + let pairing = device_info.Pairing()?; + let result = pairing.UnpairAsync()?; + + Ok(WsResponsePayload::Success(true)) +} + +#[cfg(target_os = "macos")] +pub async fn forget_device( + uuid: String, + _manager: AdapterManager, + _sender: Sender, +) -> MethodResult { + info!("forget_device not implemented on macos {}", uuid); + Ok(WsResponsePayload::Success(false)) +} diff --git a/packages/transport-bluetooth/src/server/methods/get_info.rs b/packages/transport-bluetooth/src/server/methods/get_info.rs new file mode 100644 index 00000000000..5e4dbb2345a --- /dev/null +++ b/packages/transport-bluetooth/src/server/methods/get_info.rs @@ -0,0 +1,72 @@ +use log::info; +use btleplug::api::{Central, CentralState}; + +use crate::server::adapter_manager::AdapterManager; +use crate::server::types::{MethodResult, WsResponsePayload}; +use crate::server::utils; + +#[cfg(target_os = "macos")] +pub async fn get_adapter_info() -> Result<(), Box> { + let info = std::process::Command::new("system_profiler") + .arg("-detailLevel") + .arg("full") + .arg("SPBluetoothDataType") + .output(); + + println!("get_adapter_info macos {:?}", info); + // system_profiler -detailLevel full SPBluetoothDataType + + Ok(()) +} + +// TODO: look for LMP version 10+ + +#[cfg(target_os = "linux")] +pub async fn get_adapter_info() -> Result<(), Box> { + // TODO: https://askubuntu.com/a/591813, hciconfig deprecated + let info = std::process::Command::new("hciconfig").arg("-a").output(); + + println!("get_adapter_info linux {:?}", info); + + Ok(()) +} + +#[cfg(target_os = "windows")] +pub async fn get_adapter_info() -> Result<(), Box> { + println!("get_adapter_info windows..."); + Ok(()) +} + +pub async fn get_info(manager: AdapterManager) -> MethodResult { + if let Err(err) = get_adapter_info().await { + info!("get_adapter_info error {:?}", err); + } + + let adapter = manager.get_adapter().await?; + let api_version = utils::APP_VERSION.to_string(); + if adapter.is_some() { + let adapter = adapter.clone().unwrap(); + let info = adapter + .adapter_info() + .await + .unwrap_or("Unknown".to_string()); + let state = adapter + .adapter_state() + .await + .unwrap_or(CentralState::PoweredOff); + + return Ok(WsResponsePayload::Info { + powered: state == CentralState::PoweredOn, + api_version, + adapter_info: info, + adapter_version: 9, // TODO: create platform specific util + }); + } + + return Ok(WsResponsePayload::Info { + powered: false, + api_version: utils::APP_VERSION.to_string(), + adapter_info: "Unknown".to_string(), + adapter_version: 0, + }); +} diff --git a/packages/transport-bluetooth/src/server/methods/mod.rs b/packages/transport-bluetooth/src/server/methods/mod.rs new file mode 100644 index 00000000000..6a02fb12b31 --- /dev/null +++ b/packages/transport-bluetooth/src/server/methods/mod.rs @@ -0,0 +1,23 @@ +pub mod close_device; +pub mod connect_device; +pub mod disconnect_device; +pub mod enumerate; +pub mod forget_device; +pub mod get_info; +pub mod open_device; +pub mod read; +pub mod start_scan; +pub mod stop_scan; +pub mod write; + +pub use self::close_device::close_device; +pub use self::connect_device::connect_device; +pub use self::disconnect_device::disconnect_device; +pub use self::enumerate::enumerate; +pub use self::forget_device::forget_device; +pub use self::get_info::get_info; +pub use self::open_device::open_device; +pub use self::read::read; +pub use self::start_scan::start_scan; +pub use self::stop_scan::stop_scan; +pub use self::write::write; diff --git a/packages/transport-bluetooth/src/server/methods/open_device.rs b/packages/transport-bluetooth/src/server/methods/open_device.rs new file mode 100644 index 00000000000..fdc281ad3e1 --- /dev/null +++ b/packages/transport-bluetooth/src/server/methods/open_device.rs @@ -0,0 +1,102 @@ +use btleplug::api::{CharPropFlags, Peripheral as _}; +use futures::StreamExt; +use tokio::sync::broadcast::Sender; + +use crate::server::adapter_manager::AdapterManager; +use crate::server::types::{ChannelMessage, MethodResult, NotificationEvent, WsResponsePayload}; +use crate::server::utils; + +pub async fn open_device( + uuid: String, + manager: AdapterManager, + sender: Sender, +) -> MethodResult { + let adapter = manager.get_adapter().await?; + if !(utils::is_adapter_powered(adapter.clone()).await) { + return Err("Adapted disabled")?; + } + + let device_1 = manager.get_device(uuid.clone()).await; + if device_1.is_none() { + Err("Device not found")?; + } + + let adapter = adapter.unwrap(); + let device = utils::get_peripheral_by_address(&adapter, uuid.clone()).await?; + // let device = utils::get_peripheral_by_address(&adapter, "uuid".to_string()).await?; + let is_connected = device.is_connected().await.unwrap_or(false); + if !is_connected { + return Err("Device not connected")?; + } + + // println!("device connected {:?}", device.is_connected().await); + + // On windows it throws error code HRESULT(0x800000013) "The object has been closed." - TODO: investigate + // #[cfg(target_os = "windows")] + // { + // println!("open_device on windows {:?}", device); + // } + // // On macos we need to connect again, maybe it should be done for each method? + // #[cfg(target_os = "macos")] + // { + // if let Err(err) = device.connect().await { + // eprintln!( + // "Error open_device connecting to peripheral, skipping: {}", + // err + // ); + // // return Err(Box::new(err)); + // } + // } + + device.discover_services().await?; + // let device_address = device.address().to_string(); + let characteristics = device.characteristics(); + + println!("open_device [{:?}]: {:?}", device, characteristics); + + let read = characteristics + .into_iter() + .find(|c| c.properties.contains(CharPropFlags::NOTIFY)) + .unwrap(); + device.subscribe(&read).await?; + + // let bt_device = utils::get_bluetooth_device(&device).await?; + let notification_sender = sender.clone(); + let mut notification_stream = device.notifications().await?; + let uuid_clone = uuid.clone(); + // Process while the BLE connection is not broken or stopped. + let stream_task = tokio::spawn(async move { + println!("Start device read notification_stream"); + while let Some(data) = notification_stream.next().await { + println!("Received data from [{:?}]: {:?}", data.uuid, data.value); + if let Err(err) = notification_sender.send(ChannelMessage::Notification( + NotificationEvent::DeviceRead { + uuid: uuid_clone.clone(), + data: data.value, + }, + )) { + // TODO + println!("Error in read notification_stream {:?}", err); + } + } + println!("Terminating device read notification_stream...."); + }); + + let mut receiver = sender.subscribe(); + tokio::spawn(async move { + while let Ok(event) = receiver.recv().await { + match event { + ChannelMessage::Abort(_event) => { + stream_task.abort(); + let _ = device.unsubscribe(&read).await; + println!("Terminating device read...."); + break; + } + // TODO: DeviceDisconnect + _ => {} + } + } + }); + + Ok(WsResponsePayload::Success(true)) +} diff --git a/packages/transport-bluetooth/src/server/methods/read.rs b/packages/transport-bluetooth/src/server/methods/read.rs new file mode 100644 index 00000000000..7b801e0eb5b --- /dev/null +++ b/packages/transport-bluetooth/src/server/methods/read.rs @@ -0,0 +1,14 @@ +use tokio::sync::broadcast::Sender; + +use crate::server::adapter_manager::AdapterManager; +use crate::server::types::{ChannelMessage, MethodResult, WsResponsePayload}; + +pub async fn read( + _uuid: String, + _manager: AdapterManager, + _sender: Sender, +) -> MethodResult { + // TODO: check if device is connected and opened + + Ok(WsResponsePayload::Read(vec![])) +} diff --git a/packages/transport-bluetooth/src/server/methods/start_scan.rs b/packages/transport-bluetooth/src/server/methods/start_scan.rs new file mode 100644 index 00000000000..9210f72eac3 --- /dev/null +++ b/packages/transport-bluetooth/src/server/methods/start_scan.rs @@ -0,0 +1,90 @@ +use log::info; +use tokio::sync::broadcast::Sender; + +use btleplug::api::{Central, ScanFilter}; +use btleplug::platform::Adapter; + +use crate::server::adapter_manager::AdapterManager; +use crate::server::device::SERVICE_UUID; +use crate::server::types::{ + AbortProcess, ChannelMessage, MethodResult, NotificationEvent, WsResponsePayload, +}; +use crate::server::utils; + +async fn scan(adapter: &Adapter) { + // https://github.com/deviceplug/btleplug/issues/255 + if let Err(err) = adapter.stop_scan().await { + info!("Clear previous scan error {}", err); + } + + #[cfg(target_os = "windows")] + { + // TODO: windows scan filter default, others use below + if let Err(err) = adapter.start_scan(ScanFilter::default()).await { + info!("Start scan error {}", err); + } + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + // - ScanFilter incorrectly work on windows https://github.com/deviceplug/btleplug/issues/249 (for me it returned different device) + if let Err(err) = adapter + .start_scan(ScanFilter { + // services: vec![Uuid::from_u128(0x6e400001_b5a3_f393_e0a9_e50e24dcca9e)] + // services: vec![Uuid::from_u128(0x8c000001_a59b_4d58_a9ad_073df69fa1b1)] + services: vec![SERVICE_UUID], + }) + .await + { + info!("Start scan error {}", err); + } + } +} + +pub async fn start_scan(manager: AdapterManager, sender: Sender) -> MethodResult { + let adapter = manager.get_adapter().await?; + if !(utils::is_adapter_powered(adapter.clone()).await) { + return Err("Adapted disabled")?; + } + + let adapter = adapter.unwrap(); + let known_devices = manager.enumerate().await; + println!("known {:?}", known_devices); + + scan(&adapter).await; + + let mut receiver = sender.subscribe(); + tokio::spawn(async move { + while let Ok(event) = receiver.recv().await { + match event { + ChannelMessage::Abort(event) => { + if matches!(event, AbortProcess::Scan) + || matches!(event, AbortProcess::Disconnect) + { + info!("Terminating scan...."); + break; + } + } + ChannelMessage::Notification(event) => { + match event { + NotificationEvent::AdapterStateChanged { powered } => { + if powered { + info!("Restart scan..."); + scan(&adapter).await; + } else { + // https://github.com/deviceplug/btleplug/issues/255 + if let Err(err) = adapter.stop_scan().await { + info!("Clear running scan {}", err); + } + } + } + _ => {} + } + } + _ => {} + } + } + }); + + Ok(WsResponsePayload::Peripherals(known_devices)) +} diff --git a/packages/transport-bluetooth/src/server/methods/stop_scan.rs b/packages/transport-bluetooth/src/server/methods/stop_scan.rs new file mode 100644 index 00000000000..0d261940033 --- /dev/null +++ b/packages/transport-bluetooth/src/server/methods/stop_scan.rs @@ -0,0 +1,23 @@ +use btleplug::api::Central; +use log::info; +use tokio::sync::broadcast::Sender; + +use crate::server::adapter_manager::AdapterManager; +use crate::server::types::{AbortProcess, ChannelMessage, MethodResult, WsResponsePayload}; +use crate::server::utils; + +pub async fn stop_scan(manager: AdapterManager, sender: Sender) -> MethodResult { + let _ = sender.send(ChannelMessage::Abort(AbortProcess::Scan)); + + let adapter = manager.get_adapter().await?; + if !(utils::is_adapter_powered(adapter.clone()).await) { + return Err("Adapted disabled")?; + } + + let adapter = adapter.unwrap(); + if let Err(err) = adapter.stop_scan().await { + info!("Stop scan error {}", err); + } + + Ok(WsResponsePayload::Success(true)) +} diff --git a/packages/transport-bluetooth/src/server/methods/write.rs b/packages/transport-bluetooth/src/server/methods/write.rs new file mode 100644 index 00000000000..565d81b30e7 --- /dev/null +++ b/packages/transport-bluetooth/src/server/methods/write.rs @@ -0,0 +1,52 @@ +use btleplug::api::{CharPropFlags, Peripheral as _, WriteType}; +use tokio::sync::broadcast::Sender; + +use crate::server::adapter_manager::AdapterManager; +use crate::server::device::CHARACTERISTIC_RX; +use crate::server::types::{ChannelMessage, MethodResult, WsResponsePayload}; +use crate::server::utils; + +pub async fn write( + uuid: String, + data: Vec, + manager: AdapterManager, + _sender: Sender, +) -> MethodResult { + let adapter = manager.get_adapter().await?; + if !(utils::is_adapter_powered(adapter.clone()).await) { + return Err("Adapted disabled")?; + } + + let adapter = adapter.unwrap(); + let device = utils::get_peripheral_by_address(&adapter, uuid).await?; + let is_connected = device.is_connected().await.unwrap_or(false); + if !is_connected { + return Err("Device not connected")?; + } + + device.discover_services().await?; + + let characteristics = device.characteristics(); + let cmd_char = characteristics + .iter() + .find(|c| c.uuid == CHARACTERISTIC_RX && c.properties.contains(CharPropFlags::WRITE)) + .unwrap(); + + let mut vec = vec![0; 244]; + let mut i = 0; + for val in data { + vec[i] = val; + i += 1; + } + + println!("sending {} - {}, {:?}", vec.len(), cmd_char, vec); + + let resp = device + .write(&cmd_char, &vec, WriteType::WithoutResponse) + .await + .expect("Cannot write..."); + + println!("sending complete {:?}", resp); + + Ok(WsResponsePayload::Success(true)) +} diff --git a/packages/transport-bluetooth/src/server/mod.rs b/packages/transport-bluetooth/src/server/mod.rs new file mode 100644 index 00000000000..b7b6762699a --- /dev/null +++ b/packages/transport-bluetooth/src/server/mod.rs @@ -0,0 +1,10 @@ +pub mod adapter_manager; +pub mod connection_handler; +pub mod device; +pub mod message_handler; +pub mod methods; +pub mod types; +pub mod utils; + +pub use self::connection_handler::start; +pub use self::message_handler::handle_message; diff --git a/packages/transport-bluetooth/src/server/types.rs b/packages/transport-bluetooth/src/server/types.rs new file mode 100644 index 00000000000..89033f5f43f --- /dev/null +++ b/packages/transport-bluetooth/src/server/types.rs @@ -0,0 +1,109 @@ +use crate::server::device::TrezorDevice; + +#[derive(serde::Serialize, Clone, Debug)] +pub enum AbortProcess { + Scan, + Read, + Disconnect, +} + +#[derive(serde::Serialize, Clone, Debug)] +pub enum ChannelMessage { + Abort(AbortProcess), + Response(WsResponse), + Notification(NotificationEvent), +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +#[serde(tag = "name", content = "args", rename_all = "snake_case")] +pub enum WsRequestMethod { + StartScan(), + StopScan(), + GetInfo(), + Enumerate(), + ConnectDevice(String), + DisconnectDevice(String), + OpenDevice(String), + CloseDevice(String), + Write(String, Vec), + Read(String), + ForgetDevice(String), +} + +#[derive(serde::Deserialize, Debug)] +pub struct WsRequest { + pub id: String, + pub method: WsRequestMethod, +} + +#[derive(serde::Serialize, Clone, Debug)] +#[serde(untagged)] +pub enum WsResponsePayload { + Info { + powered: bool, + api_version: String, + adapter_info: String, + adapter_version: u8, + }, + Peripherals(Vec), + Success(bool), + Data(String), + Read(Vec), +} + +#[derive(serde::Serialize, Clone, Debug)] +pub struct WsResponse { + pub id: String, + pub method: WsRequestMethod, + pub payload: WsResponsePayload, +} + +#[derive(serde::Serialize, Clone, Debug)] +pub struct WsError { + pub id: String, + pub method: WsRequestMethod, + pub error: String, +} + +#[derive(serde::Serialize, Clone, Debug)] +#[serde(tag = "event", content = "payload", rename_all = "snake_case")] +pub enum NotificationEvent { + AdapterStateChanged { + powered: bool, + }, + ScanningUpdate { + devices: Vec, + }, + DeviceDiscovered { + uuid: String, + timestamp: u64, + devices: Vec, + }, + DeviceUpdated { + uuid: String, + devices: Vec, + }, + DeviceConnected { + uuid: String, + devices: Vec, + }, + DevicePairing { + uuid: String, + paired: bool, + pin: String, + }, + DeviceConnectionStatus { + uuid: String, + phase: String, + }, + DeviceDisconnected { + uuid: String, + devices: Vec, + }, + DeviceRead { + uuid: String, + data: Vec, + }, +} + +pub type MethodResult = Result>; diff --git a/packages/transport-bluetooth/src/server/utils.rs b/packages/transport-bluetooth/src/server/utils.rs new file mode 100644 index 00000000000..4bd8875e377 --- /dev/null +++ b/packages/transport-bluetooth/src/server/utils.rs @@ -0,0 +1,70 @@ +use btleplug::api::{Central, CentralState, Manager as _, Peripheral as _}; +use btleplug::platform::{Adapter, Manager, Peripheral, PeripheralId}; +use std::error::Error; + +pub async fn get_adapter(manager: &Manager, current: Option) -> Option { + if current.is_some() { + return current; + } + let adapters = manager.adapters().await; + println!("No current adapter, get_adapter {:?}", adapters); + if adapters.is_ok() { + return adapters.unwrap().into_iter().nth(0); + } + + None +} + +pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub async fn is_adapter_powered(adapter: Option) -> bool { + match adapter { + Some(adapter) => { + let state = adapter + .adapter_state() + .await + .unwrap_or(CentralState::PoweredOff); + return state == CentralState::PoweredOn; + } + None => false, + } +} + +pub async fn get_peripheral_by_address( + adapter: &Adapter, + address: String, +) -> Result> { + let devices = adapter.peripherals().await?; + let device = devices.into_iter().find(|x| x.id().to_string() == address); + match device { + Some(device) => Ok(device), + None => Err("Peripheral not found")?, + } +} + +pub async fn get_peripheral_name(peripheral: &Peripheral) -> Result> { + let properties = peripheral.properties().await?; + let local_name: String = properties + .unwrap() + .local_name + .unwrap_or(String::from("(unknown name)")); + Ok(local_name) +} + +pub async fn scan_filter(adapter: &Adapter, id: &PeripheralId) -> Option { + let device = adapter.peripheral(&id).await; + if !device.is_ok() { + return None; + } + + let device = device.unwrap(); + let name = get_peripheral_name(&device) + .await + .unwrap_or("Unknown".to_string()); + if name.contains("Trezor") { + return Some(device); + } + + return None; +} + diff --git a/packages/transport-bluetooth/src/ui/index.css b/packages/transport-bluetooth/src/ui/index.css new file mode 100644 index 00000000000..8aaf5985ec7 --- /dev/null +++ b/packages/transport-bluetooth/src/ui/index.css @@ -0,0 +1,54 @@ +body { + background-color: rgb(13, 17, 23); + color-scheme: dark; + font-size: 14px; + color: rgb(240, 246, 252); +} + +button { + background-color: rgb(33, 40, 48); + border-radius: 4px; + border-color: rgb(61, 68, 77); + cursor: pointer; + line-height: 21px; + padding: 4px; + margin-right: 8px; +} + +#output { + max-height: 500px; + overflow: auto; + padding: 10px; +} + +#output p { + word-wrap: break-word; +} + +#device-list { + padding: 10px; +} + +.device-list-item { + display: flex; + flex-direction: row; +} + +.device-list-item-details p { + font-size: 12px; + margin: 4px; +} + +.device-list-item button { + margin-left: 12px; +} + +content { + display: flex; + flex-direction: column; +} + +section { + padding: 12px; + border-bottom: 1px solid black; +} diff --git a/packages/transport-bluetooth/src/ui/index.html b/packages/transport-bluetooth/src/ui/index.html new file mode 100644 index 00000000000..241170bf4af --- /dev/null +++ b/packages/transport-bluetooth/src/ui/index.html @@ -0,0 +1,74 @@ + + + + + + + Bluetooth transport | Trezor + + + + + + + +

+

Trezor Bluetooth transport API

+
+ + + + + +
+ + +
+ + Devices +
+ Output: +
+ +
+ + + +
+ +
+ + + + + + + + + +
+
+ +
+ + +
+ + diff --git a/packages/transport-bluetooth/src/ui/index.ts b/packages/transport-bluetooth/src/ui/index.ts new file mode 100644 index 00000000000..6df00378bc9 --- /dev/null +++ b/packages/transport-bluetooth/src/ui/index.ts @@ -0,0 +1,252 @@ +import { TrezorBle } from '../client/trezor-ble'; +import { BluetoothDevice } from '../client/types'; + +const getDeviceUuid = () => { + return (document.getElementById('connect_device_input') as HTMLInputElement).value; +}; + +const getElement = (id: string) => { + return document.getElementById(id) as HTMLElement; +}; + +const writeOutput = (message: unknown) => { + const output = document.getElementById('output') as HTMLElement; + const pre = document.createElement('p'); + try { + const json = JSON.stringify(message); + pre.innerHTML = json; + } catch { + pre.innerHTML = `${message}`; + } + + output.appendChild(pre); +}; + +const updateDeviceList = (api: TrezorBle, devices: BluetoothDevice[]) => { + const container = getElement('device-list'); + container.innerHTML = ''; + + devices.forEach(d => { + const item = document.createElement('div'); + item.className = 'device-list-item'; + const details = document.createElement('div'); + details.className = 'device-list-item-details'; + item.appendChild(details); + + const label = document.createElement('div'); + label.innerHTML = d.name + ' ' + d.uuid; + details.appendChild(label); + + let p = document.createElement('p'); + p.innerHTML = `Data (${d.data.length}): ${d.data}`; + details.appendChild(p); + + p = document.createElement('p'); + const timestamp = d.timestamp + ? new Date(d.timestamp * 1000).toLocaleTimeString('en-US', { hour12: false }) + : 'Unknown'; + p.innerHTML = `Last seen: ${timestamp}`; + details.appendChild(p); + + p = document.createElement('p'); + p.innerHTML = `Paired: ${d.paired}, Pairable: ${d.pairing_mode}, RSSI: ${d.rssi}`; + details.appendChild(p); + + const button = document.createElement('button'); + if (d.data.length === 0) { + button.setAttribute('disabled', 'disabled'); + } + button.innerHTML = d.connected ? 'Disconnect' : 'Connect'; + button.onclick = () => { + if (!d.connected) { + api.send('connect_device', d.uuid).then(r => { + writeOutput(r); + (document.getElementById('connect_device_input') as HTMLInputElement).value = + d.uuid; + }); + } else { + api.send('disconnect_device', d.uuid).catch(e => { + writeOutput({ error: e.message }); + }); + } + }; + item.appendChild(button); + + container.appendChild(item); + }); +}; + +async function init() { + const api = new TrezorBle({}); + + try { + await api.connect(); + writeOutput(`API connected.`); + } catch (e) { + writeOutput(`API not connected. ${e}`); + } + + api.on('disconnected', () => { + writeOutput('Api disconnected'); + }); + api.on('adapter_state_changed', event => { + updateDeviceList(api, []); + writeOutput(`adapter_state_changed connected: ${event.powered}`); + }); + api.on('device_discovered', event => { + updateDeviceList(api, event.devices); + }); + api.on('device_updated', event => { + updateDeviceList(api, event.devices); + }); + api.on('device_connected', event => { + updateDeviceList(api, event.devices); + }); + api.on('device_disconnected', event => { + updateDeviceList(api, event.devices); + }); + + getElement('api_connect').onclick = () => { + try { + api.connect() + .then(() => { + writeOutput('API connected'); + }) + .catch(e => { + writeOutput({ error: e.message }); + }); + } catch (e) { + writeOutput(`API not connected. ${e}`); + } + }; + + getElement('api_disconnect').onclick = () => { + api.disconnect(); + }; + + getElement('start_scan').onclick = () => { + api.send('start_scan') + .then(devices => { + updateDeviceList(api, devices); + }) + .catch(e => { + writeOutput({ error: e.message }); + }); + }; + + getElement('stop_scan').onclick = () => { + api.send('stop_scan') + .then(r => { + writeOutput(r); + }) + .catch(e => { + writeOutput({ error: e.message }); + }); + }; + + getElement('get_info').onclick = () => { + api.send('get_info') + .then(r => { + writeOutput(r); + }) + .catch(e => { + writeOutput({ error: e.message }); + }); + }; + + getElement('connect_device').onclick = () => { + const uuid = getDeviceUuid(); + api.send('connect_device', uuid) + .then(r => { + console.warn('Connect device Result!', r); + writeOutput(r); + }) + .catch(e => { + writeOutput({ error: e.message }); + }); + }; + + getElement('disconnect_device').onclick = () => { + const uuid = getDeviceUuid(); + api.send('disconnect_device', uuid) + .then(r => { + writeOutput(r); + }) + .catch(e => { + writeOutput({ error: e.message }); + }); + }; + + getElement('forget_device').onclick = () => { + const uuid = getDeviceUuid(); + api.send('forget_device', uuid) + .then(r => { + writeOutput(r); + }) + .catch(e => { + writeOutput({ error: e.message }); + }); + }; + + getElement('open_device').onclick = () => { + const uuid = getDeviceUuid(); + api.send('open_device', uuid) + .then(r => { + writeOutput(r); + }) + .catch(e => { + writeOutput({ error: e.message }); + }); + }; + + getElement('close_device').onclick = () => { + const uuid = getDeviceUuid(); + api.send('close_device', uuid) + .then(r => { + writeOutput(r); + }) + .catch(e => { + writeOutput({ error: e.message }); + }); + }; + + getElement('write').onclick = () => { + const uuid = getDeviceUuid(); + api.send('write', [uuid, [63, 35, 35, 0, 55]]) + .then(r => { + writeOutput(r); + // setTimeout(() => { + // api.read(value).then(r2 => { + // writeToScreen(r2); + // }) + // }, 1000); + }) + .catch(e => { + writeOutput({ error: e.message }); + }); + }; + + getElement('erase').onclick = () => { + const uuid = getDeviceUuid(); + api.send('write', [uuid, [63, 35, 35, 0, 27]]) + .then(r => { + writeOutput(r); + }) + .catch(e => { + writeOutput({ error: e.message }); + }); + }; + + getElement('read').onclick = () => { + const value = getDeviceUuid(); + api.send('read', value) + .then(r => { + writeOutput(r); + }) + .catch(e => { + writeOutput({ error: e.message }); + }); + }; +} + +window.addEventListener('load', init, false); diff --git a/packages/transport-bluetooth/tsconfig.json b/packages/transport-bluetooth/tsconfig.json new file mode 100644 index 00000000000..43863dcd9cc --- /dev/null +++ b/packages/transport-bluetooth/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./libDev", + "types": ["jest", "node"] + }, + "references": [ + { "path": "../ipc-proxy" }, + { "path": "../protocol" }, + { "path": "../transport" }, + { "path": "../utils" }, + { "path": "../websocket-client" } + ] +} diff --git a/packages/transport-bluetooth/webpack/webpack.build.js b/packages/transport-bluetooth/webpack/webpack.build.js new file mode 100644 index 00000000000..9f5fdd0ad95 --- /dev/null +++ b/packages/transport-bluetooth/webpack/webpack.build.js @@ -0,0 +1,104 @@ +/* eslint-disable import/no-extraneous-dependencies */ +const path = require('path'); +// TODO: not listed in dependency +const webpack = require('webpack'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); + +const PACKAGE_ROOT = path.normalize(path.join(__dirname, '..')); +const SRC = path.join(PACKAGE_ROOT, 'src/'); +const BUILD = path.join(PACKAGE_ROOT, 'build/'); + +class RemoveJSFilePlugin { + apply(compiler) { + compiler.hooks.thisCompilation.tap('RemoveJSFilePlugin', compilation => { + compilation.hooks.processAssets.tap( + { + name: 'RemoveJSFilePlugin', + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE, + }, + assets => { + // Iterate over assets and delete JavaScript files + Object.keys(assets).forEach(filename => { + if (filename.endsWith('.js')) { + compilation.deleteAsset(filename); + } + }); + }, + ); + }); + } +} + +module.exports = { + target: 'web', + mode: 'production', + entry: { + index: `${SRC}/ui/index.ts`, + }, + output: { + path: BUILD, + clean: true, + }, + module: { + rules: [ + { + test: /\.ts$/, + exclude: /node_modules/, + use: [ + { + loader: 'babel-loader', + options: { + presets: ['@babel/preset-typescript'], + }, + }, + ], + }, + ], + }, + resolve: { + modules: [SRC, 'node_modules'], + extensions: ['.ts', '.js'], + mainFields: ['main', 'module'], // prevent wrapping default exports by harmony export (bignumber.js in ripple issue) + }, + performance: { + hints: false, + }, + plugins: [ + // provide fallback plugins + new webpack.ProvidePlugin({ + Buffer: ['buffer', 'Buffer'], + process: 'process/browser', + }), + new HtmlWebpackPlugin({ + template: `${SRC}ui/index.html`, + filename: 'index.html', + inject: false, + minify: false, + templateContent: ({ compilation }) => { + const jsBundleName = Object.keys(compilation.assets).find(name => + name.endsWith('.js'), + ); + const jsBundleContent = compilation.assets[jsBundleName].source(); + const cssBundleContent = compilation.inputFileSystem.readFileSync( + `${SRC}ui/index.css`, + 'utf-8', + ); + const originalTemplate = compilation.inputFileSystem.readFileSync( + `${SRC}ui/index.html`, + 'utf-8', + ); + + return originalTemplate.replace( + '', + ``, + ); + }, + }), + new RemoveJSFilePlugin(), + ], + optimization: { + minimize: false, + }, + // ignore optional modules, dependencies of "ws" lib + externals: ['utf-8-validate', 'bufferutil'], +}; diff --git a/packages/transport-bluetooth/webpack/webpack.dev.js b/packages/transport-bluetooth/webpack/webpack.dev.js new file mode 100644 index 00000000000..e431fe7ffe2 --- /dev/null +++ b/packages/transport-bluetooth/webpack/webpack.dev.js @@ -0,0 +1,81 @@ +/* eslint-disable import/no-extraneous-dependencies */ +const path = require('path'); +const webpack = require('webpack'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); + +const PACKAGE_ROOT = path.normalize(path.join(__dirname, '..')); +const PORT = 8089; +const SRC = path.join(PACKAGE_ROOT, 'src/'); + +module.exports = { + target: 'web', + mode: 'development', + devtool: 'source-map', + entry: { + indexUI: [`${SRC}/ui/index.ts`], + index: [`${SRC}/index.ts`], + }, + stats: { + children: true, + }, + devServer: { + static: { + directory: `${SRC}ui`, + }, + hot: false, + // https: false, + port: PORT, + }, + module: { + rules: [ + { + test: input => input.includes('background-sharedworker'), + loader: 'worker-loader', + options: { + worker: 'SharedWorker', + filename: './workers/sessions-background-sharedworker.[contenthash].js', + }, + }, + { + test: /\.(js|ts)$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + options: { + presets: ['@babel/preset-typescript'], + }, + }, + }, + ], + }, + resolve: { + modules: [SRC, 'node_modules'], + extensions: ['.ts', '.js'], + mainFields: ['browser', 'module', 'main'], + fallback: { + https: false, // required by ripple-lib + crypto: require.resolve('crypto-browserify'), + stream: require.resolve('stream-browserify'), + }, + }, + performance: { + hints: false, + }, + plugins: [ + // provide fallback plugins + new webpack.ProvidePlugin({ + Buffer: ['buffer', 'Buffer'], + process: 'process/browser', + }), + new HtmlWebpackPlugin({ + chunks: ['indexUI'], + template: `${SRC}ui/index.html`, + filename: 'index.html', + inject: true, + }), + ], + optimization: { + emitOnErrors: true, + moduleIds: 'named', + }, +}; diff --git a/packages/transport-test/package.json b/packages/transport-test/package.json index e8bc7f282e6..9a98460e8ca 100644 --- a/packages/transport-test/package.json +++ b/packages/transport-test/package.json @@ -13,7 +13,7 @@ "test:e2e:new-bridge:hw": "USE_HW=true USE_NODE_BRIDGE=true yarn test:e2e:bridge", "test:e2e:new-bridge:emu": "USE_HW=false USE_NODE_BRIDGE=true yarn test:e2e:bridge", "build:e2e:api:node": "yarn esbuild ./e2e/api/api.test.ts --bundle --outfile=./e2e/dist/api.test.node.js --platform=node --target=node18 --external:usb", - "build:e2e:api:browser": "yarn esbuild ./e2e/api/api.test.ts --bundle --outfile=./e2e/dist/api.test.browser.js --platform=browser --external:usb && cp e2e/ui/api.test.html e2e/dist/index.html", + "build:e2e:api:browser": "yarn esbuild ./e2e/api/api.test.ts --bundle --log-level=verbose --outfile=./e2e/dist/api.test.browser.js --platform=browser --external:usb && cp e2e/ui/api.test.html e2e/dist/index.html", "test:e2e:api:node:hw": "yarn build:e2e:api:node && node ./e2e/dist/api.test.node.js", "test:e2e:api:browser:hw": "yarn build:e2e:api:browser && npx http-serve ./e2e/dist" }, diff --git a/packages/transport/src/api/abstract.ts b/packages/transport/src/api/abstract.ts index c0e0e41af00..36e315698df 100644 --- a/packages/transport/src/api/abstract.ts +++ b/packages/transport/src/api/abstract.ts @@ -23,6 +23,7 @@ export enum DEVICE_TYPE { TypeT2 = 3, TypeT2Boot = 4, TypeEmulator = 5, + TypeBluetooth = 6, } type AccessLock = { diff --git a/packages/transport/src/constants.ts b/packages/transport/src/constants.ts index 2c31ccd59c3..b0af4efb64c 100644 --- a/packages/transport/src/constants.ts +++ b/packages/transport/src/constants.ts @@ -29,6 +29,8 @@ export const TRANSPORT = { /* events */ START: 'transport-start', ERROR: 'transport-error', + CHANGED: 'transport-changed', + DEVICE_CONNECTED: 'transport-device_connected', DEVICE_DISCONNECTED: 'transport-device_disconnected', DEVICE_SESSION_CHANGED: 'transport-device_session_changed', @@ -37,4 +39,5 @@ export const TRANSPORT = { DISABLE_WEBUSB: 'transport-disable_webusb', REQUEST_DEVICE: 'transport-request_device', GET_INFO: 'transport-get_info', + SET_TRANSPORTS: 'transport-set_transports', } as const; diff --git a/packages/transport/src/transports/abstract.ts b/packages/transport/src/transports/abstract.ts index 5d36c138a9c..5b77918aab9 100644 --- a/packages/transport/src/transports/abstract.ts +++ b/packages/transport/src/transports/abstract.ts @@ -366,6 +366,7 @@ export abstract class AbstractTransport extends TransportEmitter { const controller = new AbortController(); const onAbort = () => controller.abort(); signal.addEventListener('abort', onAbort); + if (signal.aborted) controller.abort(signal.reason); this.abortController.signal.addEventListener('abort', onAbort); const clear = () => { signal.removeEventListener('abort', onAbort); diff --git a/packages/transport/src/types/index.ts b/packages/transport/src/types/index.ts index 0cef928a4fd..9fb702b8ae9 100644 --- a/packages/transport/src/types/index.ts +++ b/packages/transport/src/types/index.ts @@ -34,6 +34,8 @@ export type Descriptor = Omit & { debugSession?: null | Session; /** only reported by old bridge */ debug?: boolean; + /** only reported by transport-bluetooth */ + uuid?: string; }; export interface Logger { diff --git a/packages/trezor-user-env-link/package.json b/packages/trezor-user-env-link/package.json index 4b3e19655d9..821e375df03 100644 --- a/packages/trezor-user-env-link/package.json +++ b/packages/trezor-user-env-link/package.json @@ -10,12 +10,11 @@ }, "dependencies": { "@trezor/utils": "workspace:*", + "@trezor/websocket-client": "workspace:*", "cross-fetch": "^4.0.0", - "semver": "^7.6.3", - "ws": "^8.18.0" + "semver": "^7.6.3" }, "devDependencies": { - "@types/semver": "^7.5.8", - "@types/ws": "^8.5.12" + "@types/semver": "^7.5.8" } } diff --git a/packages/trezor-user-env-link/src/websocket-client.ts b/packages/trezor-user-env-link/src/websocket-client.ts index 8cc1eb8bdec..bc7333fa491 100644 --- a/packages/trezor-user-env-link/src/websocket-client.ts +++ b/packages/trezor-user-env-link/src/websocket-client.ts @@ -1,14 +1,14 @@ /* eslint-disable no-console */ -import WebSocket, { RawData } from 'ws'; import fetch from 'cross-fetch'; -import { createDeferred, Deferred, TypedEmitter } from '@trezor/utils'; +import { + WebsocketClient as WebsocketClientBase, + WebsocketResponse as WebsocketResponseData, +} from '@trezor/websocket-client'; import { Firmwares } from './types'; -const NOT_INITIALIZED = new Error('websocket_not_initialized'); - // Making the timeout high because the controller in trezor-user-env // must synchronously run actions on emulator and they may take a long time // (for example in case of Shamir backup) @@ -22,17 +22,11 @@ const USER_ENV_URL = { DASHBOARD: `http://127.0.0.1:9002`, }; -interface Options { - pingTimeout?: number; - url?: string; - timeout?: number; -} - const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); export type WebsocketClientEvents = { - firmwares: (firmwares: Firmwares) => void; - disconnected: () => void; + firmwares: Firmwares; + disconnected: undefined; }; interface WebsocketRequest { @@ -43,140 +37,33 @@ interface WebsocketResponse { response: any; } -export class WebsocketClient extends TypedEmitter { - private messageID: number; - private options: Options; - private messages: Deferred[]; - - private ws?: WebSocket; - private connectionTimeout?: NodeJS.Timeout; - private pingTimeout?: NodeJS.Timeout; - - constructor(options: Options = {}) { - super(); - this.messageID = 0; - this.messages = []; - this.setMaxListeners(Infinity); - this.options = { - ...options, - url: options.url || USER_ENV_URL.WEBSOCKET, - }; - } - - private setConnectionTimeout() { - this.clearConnectionTimeout(); - this.connectionTimeout = setTimeout( - this.onTimeout.bind(this), - this.options.timeout || DEFAULT_TIMEOUT, - ); - } - - private clearConnectionTimeout() { - if (this.connectionTimeout) { - clearTimeout(this.connectionTimeout); - this.connectionTimeout = undefined; - } - } - - private setPingTimeout() { - if (this.pingTimeout) { - clearTimeout(this.pingTimeout); - } - this.pingTimeout = setTimeout( - this.onPing.bind(this), - this.options.pingTimeout || DEFAULT_PING_TIMEOUT, - ); +export class WebsocketClient extends WebsocketClientBase { + protected createWebsocket() { + return this.initWebsocket(this.options); } - private onTimeout() { - const { ws } = this; - if (!ws) return; - if (ws.listenerCount('open') > 0) { - ws.emit('error', 'Websocket timeout'); - try { - ws.close(); - } catch { - // empty - } - } else { - this.messages.forEach(m => m.reject(new Error('websocket_timeout'))); - ws.close(); - } + protected ping() { + return this.send({ type: 'ping' }); } - private onPing() { - // make sure that connection is alive if there are subscriptions - if (this.ws && this.isConnected()) { - try { - this.ws.close(); - } catch { - // empty - } - } - } - - private onError() { - this.dispose(); + constructor(options: any = {}) { + super({ + ...options, + url: options.url || USER_ENV_URL.WEBSOCKET, + timeout: options.timeout || DEFAULT_TIMEOUT, + pingTimeout: options.pingTimeout || DEFAULT_PING_TIMEOUT, + }); } async send(params: T): Promise { // probably after update to node 18 it started to disconnect after certain // period of inactivity. await this.connect(); - const { ws } = this; - if (!ws) throw NOT_INITIALIZED; - const id = this.messageID; - - const dfd = createDeferred<{ response: any }>(id); - const req = { - id, - ...params, - }; - - this.messageID++; - this.messages.push(dfd); - - this.setConnectionTimeout(); - this.setPingTimeout(); - - ws.send(JSON.stringify(req)); - - return dfd.promise; - } - - private onmessage(message: RawData) { - try { - const resp = JSON.parse(message.toString()); - const { id, success } = resp; - const dfd = this.messages.find(m => m.id === id); - - if (resp.type === 'client') { - const { firmwares } = resp; - - this.emit('firmwares', firmwares); - } - - if (dfd) { - if (!success) { - dfd.reject( - new Error(`websocket_error_message: ${resp.error.message || resp.error}`), - ); - } else { - dfd.resolve(resp); - } - this.messages.splice(this.messages.indexOf(dfd), 1); - } - } catch (error) { - console.error('websocket onmessage error: ', error); - } - if (this.messages.length === 0) { - this.clearConnectionTimeout(); - } - this.setPingTimeout(); + return this.sendMessage(params); } - public async connect() { + async connect() { if (this.isConnected()) return Promise.resolve(); // workaround for karma... proper fix: set allow origin headers in trezor-user-env server. but we are going @@ -185,92 +72,33 @@ export class WebsocketClient extends TypedEmitter { await this.waitForTrezorUserEnv(); } - return new Promise(resolve => { - // url validation - let { url } = this.options; - if (typeof url !== 'string') { - throw new Error('websocket_no_url'); - } - - if (url.startsWith('https')) { - url = url.replace('https', 'wss'); - } - if (url.startsWith('http')) { - url = url.replace('http', 'ws'); - } - - // set connection timeout before WebSocket initialization - // it will be be cancelled by this.init or this.dispose after the error - this.setConnectionTimeout(); - - // initialize connection - const ws = new WebSocket(url); - - ws.once('error', error => { - console.error('websocket error', error); - this.dispose(); - }); - - this.on('firmwares', () => { - resolve(this); - }); - - this.ws = ws; - - ws.on('open', () => { - this.init(); + return new Promise(resolve => { + super.connect().then(() => { + this.once('firmwares', () => resolve()); }); }); } - private init() { - const { ws } = this; - if (!ws || !this.isConnected()) { - throw Error('Websocket init cannot be called'); - } - // clear timeout from this.connect - this.clearConnectionTimeout(); - - // remove previous listeners and add new listeners - ws.removeAllListeners(); - ws.on('error', this.onError.bind(this)); - ws.on('message', this.onmessage.bind(this)); - ws.on('close', () => { - this.emit('disconnected'); - this.dispose(); - }); - } - - public disconnect() { - if (this.ws) { - this.ws.close(); - } - this.dispose(); - } - - private isConnected() { - const { ws } = this; - - return ws && ws.readyState === WebSocket.OPEN; - } + protected onMessage(message: WebsocketResponseData) { + try { + const resp = JSON.parse(message.toString()); + const { id, success } = resp; - private dispose() { - if (this.pingTimeout) { - clearTimeout(this.pingTimeout); - } - if (this.connectionTimeout) { - clearTimeout(this.connectionTimeout); - } + if (resp.type === 'client') { + this.emit('firmwares', resp.firmwares); + } - const { ws } = this; - if (this.isConnected()) { - this.disconnect(); - } - if (ws) { - ws.removeAllListeners(); + if (!success) { + this.messages.reject( + Number(id), + new Error(`websocket_error_message: ${resp.error.message || resp.error}`), + ); + } else { + this.messages.resolve(Number(id), resp); + } + } catch { + // empty } - - this.removeAllListeners(); } async waitForTrezorUserEnv() { diff --git a/packages/trezor-user-env-link/tsconfig.json b/packages/trezor-user-env-link/tsconfig.json index 0ec4519c33d..cb94bd20b74 100644 --- a/packages/trezor-user-env-link/tsconfig.json +++ b/packages/trezor-user-env-link/tsconfig.json @@ -1,5 +1,8 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "libDev" }, - "references": [{ "path": "../utils" }] + "references": [ + { "path": "../utils" }, + { "path": "../websocket-client" } + ] } diff --git a/packages/websocket-client/README.md b/packages/websocket-client/README.md new file mode 100644 index 00000000000..fc690407903 --- /dev/null +++ b/packages/websocket-client/README.md @@ -0,0 +1,5 @@ +# @trezor/websocket-client + +[![NPM](https://img.shields.io/npm/v/@trezor/websocket-client.svg)](https://www.npmjs.org/package/@trezor/websocket-client) + +Shared websocket client implementation diff --git a/packages/websocket-client/package.json b/packages/websocket-client/package.json new file mode 100644 index 00000000000..3322750565f --- /dev/null +++ b/packages/websocket-client/package.json @@ -0,0 +1,52 @@ +{ + "name": "@trezor/websocket-client", + "version": "1.0.0", + "author": "Trezor ", + "homepage": "https://github.com/trezor/trezor-suite/tree/develop/packages/websocket", + "description": "Shared websocket client implementation", + "npmPublishAccess": "public", + "license": "SEE LICENSE IN LICENSE.md", + "repository": { + "type": "git", + "url": "git://github.com/trezor/trezor-suite.git" + }, + "bugs": { + "url": "https://github.com/trezor/trezor-suite/issues" + }, + "sideEffects": false, + "main": "src/index", + "browser": { + "ws": "./src/ws-browser" + }, + "react-native": { + "__comment__": "Hotfix for issue where RN metro bundler resolve relatives paths wrong", + "ws": "@trezor/websocket-client/src/ws-native.ts" + }, + "publishConfig": { + "main": "./lib/index.js", + "types": "lib/index.d.ts", + "typings": "lib/index.d.ts", + "browser": { + "ws": "./lib/ws-browser.js" + }, + "react-native": { + "__comment__": "Hotfix for issue where RN metro bundler resolve relatives paths wrong", + "ws": "@trezor/websocket-client/lib/ws-native.js" + } + }, + "scripts": { + "depcheck": "yarn g:depcheck", + "test:unit": "jest -c ../../jest.config.base.js", + "type-check": "yarn g:tsc --build", + "build:lib": "yarn g:rimraf lib && yarn g:tsc --build tsconfig.lib.json && ../../scripts/replace-imports.sh ./lib", + "prepublishOnly": "yarn tsx ../../scripts/prepublishNPM.js", + "prepublish": "yarn tsx ../../scripts/prepublish.js" + }, + "dependencies": { + "@trezor/utils": "workspace:*", + "ws": "^8.18.0" + }, + "peerDependencies": { + "tslib": "^2.6.2" + } +} diff --git a/packages/websocket-client/src/client.ts b/packages/websocket-client/src/client.ts new file mode 100644 index 00000000000..163453a16ca --- /dev/null +++ b/packages/websocket-client/src/client.ts @@ -0,0 +1,221 @@ +import WebSocket from 'ws'; + +import { createDeferred, createDeferredManager, TypedEmitter } from '@trezor/utils'; + +interface Options { + url: string; + timeout?: number; + pingTimeout?: number; + connectionTimeout?: number; + keepAlive?: boolean; + agent?: WebSocket.ClientOptions['agent']; + headers?: WebSocket.ClientOptions['headers']; + onSending?: (message: Record) => void; +} + +const DEFAULT_TIMEOUT = 20 * 1000; +const DEFAULT_PING_TIMEOUT = 50 * 1000; + +type WsEvents = { + error: string; + disconnected: undefined; +}; + +export type WebsocketRequest = Record; +export type WebsocketResponse = WebSocket.Data; + +export abstract class WebsocketClient extends TypedEmitter< + T & WsEvents +> { + readonly options: Options; + + public readonly messages; + private readonly emitter: TypedEmitter = this; + + private ws?: WebSocket; + private pingTimeout?: ReturnType; + private connectPromise?: Promise; + + protected abstract createWebsocket(): WebSocket; + protected abstract ping(): Promise; + + constructor(options: Options) { + super(); + this.options = options; + this.messages = createDeferredManager({ + timeout: this.options.timeout || DEFAULT_TIMEOUT, + onTimeout: this.onTimeout.bind(this), + }); + } + + protected initWebsocket(options: Options) { + // url validation + let { url } = options; + if (typeof url !== 'string') { + throw new Error('websocket_no_url'); + } + if (url.startsWith('https')) { + url = url.replace('https', 'wss'); + } + if (url.startsWith('http')) { + url = url.replace('http', 'ws'); + } + + return new WebSocket(url, options); + } + + private setPingTimeout() { + if (this.pingTimeout) { + clearTimeout(this.pingTimeout); + } + + const onPing = async () => { + if (this.ws && this.isConnected()) { + try { + await this.onPing(); + } catch { + // empty + } + } + }; + this.pingTimeout = setTimeout(onPing, this.options.pingTimeout || DEFAULT_PING_TIMEOUT); + } + + protected onPing() { + return this.ping(); + } + + private onTimeout() { + const { ws } = this; + if (!ws) return; + this.messages.rejectAll(new Error('websocket_timeout')); + ws.close(); + } + + private onError() { + this.onClose(); + } + + protected sendMessage(message: WebsocketRequest) { + const { ws } = this; + if (!ws) throw new Error('websocket_not_initialized'); + const { promiseId, promise } = this.messages.create(); + + const req = { id: promiseId.toString(), ...message }; + + this.setPingTimeout(); + + this.options.onSending?.(message); + + ws.send(JSON.stringify(req)); + + return promise; + } + + protected onMessage(message: WebsocketResponse) { + try { + const resp = JSON.parse(message.toString()); + const { id, data } = resp; + if (data.error) { + this.messages.reject(Number(id), new Error(data.error.message)); + } else { + this.messages.resolve(Number(id), data); + } + } catch { + // empty + } + + this.setPingTimeout(); + } + + async connect() { + // if connecting already, just return the promise + if (this.connectPromise) { + return this.connectPromise; + } + + if (this.isConnected()) return Promise.resolve(); + + if (this.ws?.readyState === WebSocket.CLOSING) { + await new Promise(resolve => this.emitter.once('disconnected', resolve)); + } + + // create deferred promise + const dfd = createDeferred(-1); + this.connectPromise = dfd.promise; + + const ws = this.createWebsocket(); + + // set connection timeout before WebSocket initialization + const connectionTimeout = setTimeout( + () => { + ws.emit('error', new Error('websocket_timeout')); + try { + ws.once('error', () => {}); // hack; ws throws uncaughtably when there's no error listener + ws.close(); + } catch { + // empty + } + }, + this.options.connectionTimeout || this.options.timeout || DEFAULT_TIMEOUT, + ); + + ws.once('error', error => { + clearTimeout(connectionTimeout); + this.onClose(); + dfd.reject(new Error(error.message)); + }); + ws.on('open', () => { + clearTimeout(connectionTimeout); + this.init(); + dfd.resolve(); + }); + + this.ws = ws; + + // wait for onopen event + return dfd.promise.finally(() => { + this.connectPromise = undefined; + }); + } + + private init() { + const { ws } = this; + if (!ws || !this.isConnected()) { + throw Error('Websocket init cannot be called'); + } + + // remove previous listeners and add new listeners + ws.removeAllListeners(); + ws.on('error', this.onError.bind(this)); + ws.on('message', this.onMessage.bind(this)); + ws.on('close', () => { + this.onClose(); + }); + } + + disconnect() { + this.emitter.emit('disconnected'); + this.ws?.close(); + } + + isConnected() { + return this.ws?.readyState === WebSocket.OPEN; + } + + private onClose() { + if (this.pingTimeout) { + clearTimeout(this.pingTimeout); + } + + this.disconnect(); + + this.ws?.removeAllListeners(); + this.messages.rejectAll(new Error('Websocket closed unexpectedly')); + } + + dispose() { + this.removeAllListeners(); + this.onClose(); + } +} diff --git a/packages/websocket-client/src/index.ts b/packages/websocket-client/src/index.ts new file mode 100644 index 00000000000..d7240b7a39b --- /dev/null +++ b/packages/websocket-client/src/index.ts @@ -0,0 +1 @@ +export { WebsocketClient, type WebsocketRequest, type WebsocketResponse } from './client'; diff --git a/packages/blockchain-link/src/utils/ws.ts b/packages/websocket-client/src/ws-browser.ts similarity index 96% rename from packages/blockchain-link/src/utils/ws.ts rename to packages/websocket-client/src/ws-browser.ts index 19d8386eef1..869a3ed0940 100644 --- a/packages/blockchain-link/src/utils/ws.ts +++ b/packages/websocket-client/src/ws-browser.ts @@ -54,4 +54,5 @@ class WSWrapper extends EventEmitter { } } +// eslint-disable-next-line import/no-default-export export default WSWrapper; diff --git a/packages/blockchain-link/src/utils/ws-native.ts b/packages/websocket-client/src/ws-native.ts similarity index 97% rename from packages/blockchain-link/src/utils/ws-native.ts rename to packages/websocket-client/src/ws-native.ts index 84e0e8b0377..51fd276bc86 100644 --- a/packages/blockchain-link/src/utils/ws-native.ts +++ b/packages/websocket-client/src/ws-native.ts @@ -60,4 +60,5 @@ class WSWrapper extends EventEmitter { } } +// eslint-disable-next-line import/no-default-export export default WSWrapper; diff --git a/packages/websocket-client/tests/client.test.ts b/packages/websocket-client/tests/client.test.ts new file mode 100644 index 00000000000..d9e632ad5f4 --- /dev/null +++ b/packages/websocket-client/tests/client.test.ts @@ -0,0 +1,36 @@ +import { WebsocketClient } from '../src/client'; + +class Cli extends WebsocketClient<{ 'foo-event': 'bar-event' }> { + createWebsocket() { + return this.initWebsocket(this.options); + } + ping() { + return Promise.resolve(); + } + + sendMessage(_message: Record) { + return Promise.resolve({ success: true }); + } +} + +describe('WebsocketClient', () => { + it('throws error on connection', async () => { + const cli = new Cli({ url: 'invalid-url' }); + + await expect(() => cli.connect()).rejects.toThrow('invalid-url'); + + // types check: + + cli.on('foo-event', event => { + if (event === 'bar-event') { + // + } + }); + const resp = await cli.sendMessage({ foo: 'bar' }); + if (resp.success) { + expect(resp).toEqual({ success: true }); + } else { + expect(resp).toEqual({ success: false }); + } + }); +}); diff --git a/packages/websocket-client/tsconfig.json b/packages/websocket-client/tsconfig.json new file mode 100644 index 00000000000..0ec4519c33d --- /dev/null +++ b/packages/websocket-client/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "libDev" }, + "references": [{ "path": "../utils" }] +} diff --git a/packages/websocket-client/tsconfig.lib.json b/packages/websocket-client/tsconfig.lib.json new file mode 100644 index 00000000000..c9e91da72f4 --- /dev/null +++ b/packages/websocket-client/tsconfig.lib.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.lib.json", + "compilerOptions": { + "outDir": "./lib", + "lib": ["webworker"], + "types": ["jest", "node", "web"] + }, + "include": ["./src"], + "references": [ + { + "path": "../utils" + } + ] +} diff --git a/suite-common/connect-init/src/cardanoConnectPatch.ts b/suite-common/connect-init/src/cardanoConnectPatch.ts index 03880fe633e..ccc2f669d3b 100644 --- a/suite-common/connect-init/src/cardanoConnectPatch.ts +++ b/suite-common/connect-init/src/cardanoConnectPatch.ts @@ -8,6 +8,7 @@ type ConnectKey = keyof typeof TrezorConnect; const blacklist: ConnectKey[] = [ 'manifest', 'init', + 'setTransports', 'getSettings', 'on', 'off', diff --git a/yarn.lock b/yarn.lock index bddf3412d72..0cdbb9ea6bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11599,6 +11599,7 @@ __metadata: "@trezor/type-utils": "workspace:*" "@trezor/utils": "workspace:*" "@trezor/utxo-lib": "workspace:*" + "@trezor/websocket-client": "workspace:*" "@types/web": "npm:^0.0.174" events: "npm:^3.3.0" fs-extra: "npm:^11.2.0" @@ -11611,7 +11612,6 @@ __metadata: webpack-cli: "npm:^5.1.4" webpack-dev-server: "npm:^5.0.4" worker-loader: "npm:^3.0.8" - ws: "npm:^8.18.0" peerDependencies: tslib: ^2.6.2 languageName: unknown @@ -12391,6 +12391,7 @@ __metadata: "@trezor/request-manager": "workspace:*" "@trezor/suite": "workspace:*" "@trezor/suite-desktop-api": "workspace:*" + "@trezor/transport-bluetooth": "workspace:*" "@trezor/transport-bridge": "workspace:*" "@trezor/trezor-user-env-link": "workspace:*" "@trezor/type-utils": "workspace:*" @@ -12591,6 +12592,7 @@ __metadata: "@trezor/suite-desktop-api": "workspace:*" "@trezor/suite-storage": "workspace:*" "@trezor/theme": "workspace:*" + "@trezor/transport-bluetooth": "workspace:*" "@trezor/type-utils": "workspace:*" "@trezor/urls": "workspace:*" "@trezor/utils": "workspace:*" @@ -12666,6 +12668,22 @@ __metadata: languageName: unknown linkType: soft +"@trezor/transport-bluetooth@workspace:*, @trezor/transport-bluetooth@workspace:packages/transport-bluetooth": + version: 0.0.0-use.local + resolution: "@trezor/transport-bluetooth@workspace:packages/transport-bluetooth" + dependencies: + "@trezor/ipc-proxy": "workspace:*" + "@trezor/protocol": "workspace:*" + "@trezor/transport": "workspace:*" + "@trezor/utils": "workspace:*" + "@trezor/websocket-client": "workspace:*" + webpack: "npm:^5.96.1" + webpack-cli: "npm:^5.1.4" + webpack-dev-server: "npm:^5.0.4" + ws: "npm:^8.18.0" + languageName: unknown + linkType: soft + "@trezor/transport-bridge@workspace:*, @trezor/transport-bridge@workspace:packages/transport-bridge": version: 0.0.0-use.local resolution: "@trezor/transport-bridge@workspace:packages/transport-bridge" @@ -12752,11 +12770,10 @@ __metadata: resolution: "@trezor/trezor-user-env-link@workspace:packages/trezor-user-env-link" dependencies: "@trezor/utils": "workspace:*" + "@trezor/websocket-client": "workspace:*" "@types/semver": "npm:^7.5.8" - "@types/ws": "npm:^8.5.12" cross-fetch: "npm:^4.0.0" semver: "npm:^7.6.3" - ws: "npm:^8.18.0" languageName: unknown linkType: soft @@ -12832,6 +12849,17 @@ __metadata: languageName: unknown linkType: soft +"@trezor/websocket-client@workspace:*, @trezor/websocket-client@workspace:packages/websocket-client": + version: 0.0.0-use.local + resolution: "@trezor/websocket-client@workspace:packages/websocket-client" + dependencies: + "@trezor/utils": "workspace:*" + ws: "npm:^8.18.0" + peerDependencies: + tslib: ^2.6.2 + languageName: unknown + linkType: soft + "@trysound/sax@npm:0.2.0": version: 0.2.0 resolution: "@trysound/sax@npm:0.2.0"