From fd5bc6e101b227f4efac5d122a841a4f2384fc28 Mon Sep 17 00:00:00 2001 From: mroz Date: Thu, 6 Oct 2022 16:10:05 +0200 Subject: [PATCH] feat: nodusb concept --- ci/test.yml | 5 +- .../src/actions/trezorConnectActions.ts | 2 + .../webpack/prod.webpack.config.ts | 25 +- packages/connect-web/src/iframe/index.ts | 5 + packages/connect/e2e/common.setup.js | 4 +- .../connect/e2e/tests/device/methods.test.ts | 6 +- packages/connect/src/core/index.ts | 15 +- packages/connect/src/data/connectSettings.ts | 8 + .../connect/src/device/DescriptorStream.ts | 187 ----------- packages/connect/src/device/Device.ts | 49 ++- packages/connect/src/device/DeviceCommands.ts | 24 +- packages/connect/src/device/DeviceList.ts | 283 ++++++++++------- packages/connect/src/index.ts | 3 +- packages/connect/src/types/settings.ts | 3 +- .../connect/src/workers/workers-browser.ts | 20 +- .../src/workers/workers-react-native.ts | 8 +- packages/suite-desktop/src/modules/index.ts | 2 +- .../e2e/tests/onboarding/transport.test.ts | 2 +- .../Preloader/__tests__/Preloader.test.tsx | 4 +- .../suite/src/support/extraDependencies.ts | 14 +- .../utils/suite/__fixtures__/messageSystem.ts | 4 +- .../utils/suite/__tests__/transport.test.ts | 2 +- packages/suite/src/utils/suite/transport.ts | 2 +- packages/transport/e2e/fixtures/api.ts | 40 +++ packages/transport/e2e/tests/api.meow.ts | 50 +++ packages/transport/e2e/tests/api2.meow.ts | 166 ++++++++++ packages/transport/e2e/tests/bridge.test.ts | 72 ++--- packages/transport/e2e/tests/nodeusb.meow.ts | 113 +++++++ packages/transport/jest.config.e2e.js | 6 + packages/transport/jest.config.js | 3 +- packages/transport/package.json | 9 +- packages/transport/src/bridge/v2.ts | 184 ----------- packages/transport/src/constants.ts | 22 ++ packages/transport/src/fallback.ts | 124 -------- packages/transport/src/index.ts | 32 +- .../src/lowlevel/protobuf/messages.ts | 7 - .../transport/src/lowlevel/protocol/decode.ts | 2 +- .../transport/src/lowlevel/protocol/encode.ts | 2 +- .../transport/src/lowlevel/sharedPlugin.ts | 28 -- packages/transport/src/lowlevel/webusb.ts | 15 +- packages/transport/src/session.ts | 223 +++++++++++++ packages/transport/src/sessions/backend.ts | 188 +++++++++++ packages/transport/src/sessions/client.ts | 53 ++++ .../transport/src/sessions/sharedWorker.ts | 6 + packages/transport/src/sessions/types.ts | 9 + packages/transport/src/transports/abstract.ts | 213 +++++++++++++ packages/transport/src/transports/bridge.ts | 198 ++++++++++++ .../src/transports/nodeusb.browser.ts | 12 + packages/transport/src/transports/nodeusb.ts | 26 ++ packages/transport/src/transports/usb.ts | 294 ++++++++++++++++++ .../src/transports/webusb.browser.ts | 20 ++ packages/transport/src/transports/webusb.ts | 10 + .../withSharedConnections.ts | 177 +++++------ packages/transport/src/types/index.ts | 50 +-- .../src/utils/getAvailableTransport.ts | 16 + .../transport/src/utils/highlevel-checks.ts | 8 +- .../transport/src/{bridge => utils}/http.ts | 0 .../sharedConnectionWorker.ts | 8 +- .../transport/tests/build-receive.test.ts | 7 +- .../transport/tests/encode-decode.test.ts | 2 +- packages/transport/tests/messages.test.ts | 2 +- packages/transport/tests/sessions.test.ts | 71 +++++ yarn.lock | 24 +- 63 files changed, 2224 insertions(+), 945 deletions(-) delete mode 100644 packages/connect/src/device/DescriptorStream.ts create mode 100644 packages/transport/e2e/fixtures/api.ts create mode 100644 packages/transport/e2e/tests/api.meow.ts create mode 100644 packages/transport/e2e/tests/api2.meow.ts create mode 100644 packages/transport/e2e/tests/nodeusb.meow.ts create mode 100644 packages/transport/jest.config.e2e.js delete mode 100644 packages/transport/src/bridge/v2.ts create mode 100644 packages/transport/src/constants.ts delete mode 100644 packages/transport/src/fallback.ts delete mode 100644 packages/transport/src/lowlevel/sharedPlugin.ts create mode 100644 packages/transport/src/session.ts create mode 100644 packages/transport/src/sessions/backend.ts create mode 100644 packages/transport/src/sessions/client.ts create mode 100644 packages/transport/src/sessions/sharedWorker.ts create mode 100644 packages/transport/src/sessions/types.ts create mode 100644 packages/transport/src/transports/abstract.ts create mode 100644 packages/transport/src/transports/bridge.ts create mode 100644 packages/transport/src/transports/nodeusb.browser.ts create mode 100644 packages/transport/src/transports/nodeusb.ts create mode 100644 packages/transport/src/transports/usb.ts create mode 100644 packages/transport/src/transports/webusb.browser.ts create mode 100644 packages/transport/src/transports/webusb.ts rename packages/transport/src/{lowlevel => transports}/withSharedConnections.ts (70%) create mode 100644 packages/transport/src/utils/getAvailableTransport.ts rename packages/transport/src/{bridge => utils}/http.ts (100%) rename packages/transport/src/{lowlevel => workers}/sharedConnectionWorker.ts (97%) create mode 100644 packages/transport/tests/sessions.test.ts diff --git a/ci/test.yml b/ci/test.yml index 2ebb6ba43841..2346a2c57a6c 100644 --- a/ci/test.yml +++ b/ci/test.yml @@ -173,8 +173,9 @@ suite desktop manual: transport: extends: .e2e transport - only: - <<: *run_everything_rules + # for development purposes now, run always + # only: + # <<: *run_everything_rules transport manual: extends: .e2e transport diff --git a/packages/connect-explorer/src/actions/trezorConnectActions.ts b/packages/connect-explorer/src/actions/trezorConnectActions.ts index 2debff8faded..6995669ac7bf 100644 --- a/packages/connect-explorer/src/actions/trezorConnectActions.ts +++ b/packages/connect-explorer/src/actions/trezorConnectActions.ts @@ -53,11 +53,13 @@ export const init = console.log('using @trezor/connect hosted on: ', window.__TREZOR_CONNECT_SRC); } + const transports = ['bridge']; const connectOptions = { transportReconnect: true, popup: true, debug: true, lazyLoad: true, + transports, manifest: { email: 'info@trezor.io', appUrl: '@trezor/suite', diff --git a/packages/connect-iframe/webpack/prod.webpack.config.ts b/packages/connect-iframe/webpack/prod.webpack.config.ts index 0277a9b18ad0..1ab4fb35b19b 100644 --- a/packages/connect-iframe/webpack/prod.webpack.config.ts +++ b/packages/connect-iframe/webpack/prod.webpack.config.ts @@ -32,15 +32,15 @@ export default { }, }, }, - { - test: /sharedConnectionWorker/i, - loader: 'worker-loader', - issuer: /workers\/workers-*/i, // replace import ONLY in /workers\/workers- not @trezor/transport - options: { - worker: 'SharedWorker', - filename: './workers/shared-connection-worker.[contenthash].js', - }, - }, + // { + // test: /sharedConnectionWorker/i, + // loader: 'worker-loader', + // issuer: /workers\/workers-*/i, // replace import ONLY in /workers\/workers- not @trezor/transport + // options: { + // worker: 'SharedWorker', + // filename: './workers/shared-connection-worker.[contenthash].js', + // }, + // }, { test: /\workers\/blockbook\/index/i, loader: 'worker-loader', @@ -78,6 +78,10 @@ export default { crypto: require.resolve('crypto-browserify'), // required by multiple dependencies stream: require.resolve('stream-browserify'), // required by utxo-lib and keccak events: require.resolve('events'), + // nodeusb, but it should't be needed, since it is only in desktop + // path: require.resolve('path-browserify'), + // nodeusb + // os: require.resolve('os-browserify/browser'), }, }, performance: { @@ -98,6 +102,9 @@ export default { new webpack.NormalModuleReplacementPlugin(/\/utils\/assets$/, resource => { resource.request = resource.request.replace(/assets$/, 'assets-browser'); }), + // resolve @trezor/transport module as "browser" + // ? ??? + // copy public files new CopyWebpackPlugin({ patterns: [ diff --git a/packages/connect-web/src/iframe/index.ts b/packages/connect-web/src/iframe/index.ts index bb52826d6a33..68270337ee24 100644 --- a/packages/connect-web/src/iframe/index.ts +++ b/packages/connect-web/src/iframe/index.ts @@ -88,9 +88,14 @@ export const init = async (settings: ConnectSettings) => { } instance.setAttribute('src', src); + + // todo: webusb deprecated, use transports[] if (settings.webusb) { instance.setAttribute('allow', 'usb'); } + if (settings.transports?.includes('webusb')) { + instance.setAttribute('allow', 'usb'); + } origin = getOrigin(instance.src); timeout = window.setTimeout(() => { diff --git a/packages/connect/e2e/common.setup.js b/packages/connect/e2e/common.setup.js index 8395751e3bd1..375415c81f99 100644 --- a/packages/connect/e2e/common.setup.js +++ b/packages/connect/e2e/common.setup.js @@ -108,11 +108,13 @@ const initTrezorConnect = async (TrezorUserEnvLink, options) => { appUrl: 'tests.connect.trezor.io', email: 'tests@connect.trezor.io', }, - webusb: false, + webusb: false, // deprecated, use transports + transports: ['bridge'], debug: false, popup: false, pendingTransportEvent: true, connectSrc: process.env.TREZOR_CONNECT_SRC, // custom source for karma tests + ...options, }); }; diff --git a/packages/connect/e2e/tests/device/methods.test.ts b/packages/connect/e2e/tests/device/methods.test.ts index 72194e6cb8e6..327bf716be4f 100644 --- a/packages/connect/e2e/tests/device/methods.test.ts +++ b/packages/connect/e2e/tests/device/methods.test.ts @@ -45,10 +45,10 @@ describe(`TrezorConnect methods`, () => { controller = undefined; }); } - await setup(controller, testCase.setup); await initTrezorConnect(controller); + // done(); } catch (error) { console.log('Controller WS init error', error); @@ -60,7 +60,6 @@ describe(`TrezorConnect methods`, () => { TrezorConnect.dispose(); done(); }); - testCase.tests.forEach(t => { // check if test should be skipped on current configuration conditionalTest( @@ -82,6 +81,7 @@ describe(`TrezorConnect methods`, () => { controller.options.name = t.description; // @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 }; @@ -97,9 +97,7 @@ describe(`TrezorConnect methods`, () => { } }); } - expect(result).toMatchObject(expected); - // done(); }, t.customTimeout || 20000, ); diff --git a/packages/connect/src/core/index.ts b/packages/connect/src/core/index.ts index c712f78188f2..5262ab26094d 100644 --- a/packages/connect/src/core/index.ts +++ b/packages/connect/src/core/index.ts @@ -200,7 +200,7 @@ const initDevice = async (method: AbstractMethod) => { throw ERRORS.TypedError('Transport_Missing'); } - const isWebUsb = _deviceList.transportType() === 'WebUsbPlugin'; + const isWebUsb = _deviceList.transportType() === 'WebusbTransport'; let device: Device | typeof undefined; let showDeviceSelection = isWebUsb; if (method.devicePath) { @@ -908,7 +908,8 @@ const initDeviceList = async (settings: ConnectSettings) => { }); _deviceList.on(TRANSPORT.ERROR, async error => { - _log.warn('TRANSPORT.ERROR', error); + console.log('core _deviceList.on.TRANSPORT.ERROR error=', error); + // _log.warn('TRANSPORT.ERROR', error); if (_deviceList) { _deviceList.disconnectDevices(); _deviceList.dispose(); @@ -930,9 +931,12 @@ const initDeviceList = async (settings: ConnectSettings) => { await _deviceList.init(); if (_deviceList) { - await _deviceList.waitForTransportFirstEvent(); + console.log('==core initDeviceList, waitForTransportFirstEvent, awaiting...'); + // await _deviceList.waitForTransportFirstEvent(); + console.log('==core initDeviceList, waitForTransportFirstEvent, done'); } } catch (error) { + console.log('core, initDeviceList, error', error); _deviceList = undefined; postMessage(createTransportMessage(TRANSPORT.ERROR, { error })); if (!settings.transportReconnect) { @@ -1021,7 +1025,9 @@ export const initTransport = async (settings: ConnectSettings) => { try { if (!settings.transportReconnect) { // try only once, if it fails kill and throw initialization error + console.log('===initTransport, initDeviceList awaiting... '); await initDeviceList(settings); + console.log('===initTransport, initDeviceList done '); } else { // don't wait for DeviceList result, further communication will be thru TRANSPORT events initDeviceList(settings); @@ -1034,10 +1040,11 @@ export const initTransport = async (settings: ConnectSettings) => { const disableWebUSBTransport = async () => { if (!_deviceList) return; - if (_deviceList.transportType() !== 'WebUsbPlugin') return; + if (_deviceList.transportType() !== 'WebusbTransport') return; // override settings const settings = DataManager.getSettings(); settings.webusb = false; + // todo: settings.transports.splice(...meow) try { // disconnect previous device list diff --git a/packages/connect/src/data/connectSettings.ts b/packages/connect/src/data/connectSettings.ts index f7d82dee0b6a..dbd31504b9dc 100644 --- a/packages/connect/src/data/connectSettings.ts +++ b/packages/connect/src/data/connectSettings.ts @@ -25,7 +25,9 @@ const initialSettings: ConnectSettings = { popup: true, popupSrc: `${DEFAULT_DOMAIN}popup.html`, webusbSrc: `${DEFAULT_DOMAIN}webusb.html`, + // deprecated, use transports instead webusb: true, + transports: ['bridge'], pendingTransportEvent: true, supportedBrowser: typeof navigator !== 'undefined' ? !/Trident|MSIE|Edge/.test(navigator.userAgent) : true, // TODO: https://github.com/trezor/trezor-suite/issues/5319 @@ -147,10 +149,16 @@ export const parseConnectSettings = (input: Partial = {}) => { settings.transportReconnect = input.transportReconnect; } + // todo: deprecated if (typeof input.webusb === 'boolean') { settings.webusb = input.webusb; } + if (Array.isArray(input.transports)) { + // todo: validate + settings.transports = input.transports; + } + if (typeof input.popup === 'boolean') { settings.popup = input.popup; } diff --git a/packages/connect/src/device/DescriptorStream.ts b/packages/connect/src/device/DescriptorStream.ts deleted file mode 100644 index 95d12a37c909..000000000000 --- a/packages/connect/src/device/DescriptorStream.ts +++ /dev/null @@ -1,187 +0,0 @@ -// original file https://github.com/trezor/connect/blob/develop/src/js/device/DescriptorStream.js - -// This file reads descriptor with very little logic, and sends it to layers above - -import EventEmitter from 'events'; -import type { Transport, TrezorDeviceInfoWithSession as DeviceDescriptor } from '@trezor/transport'; -import { TRANSPORT } from '../events/transport'; -import { DEVICE } from '../events/device'; - -import { initLog } from '../utils/debug'; -import { DataManager } from '../data/DataManager'; -import { resolveAfter } from '../utils/promiseUtils'; - -export type DeviceDescriptorDiff = { - didUpdate: boolean; - connected: DeviceDescriptor[]; - disconnected: DeviceDescriptor[]; - changedSessions: DeviceDescriptor[]; - changedDebugSessions: DeviceDescriptor[]; - acquired: DeviceDescriptor[]; - debugAcquired: DeviceDescriptor[]; - released: DeviceDescriptor[]; - debugReleased: DeviceDescriptor[]; - descriptors: DeviceDescriptor[]; -}; - -// custom log -const logger = initLog('DescriptorStream'); - -const getDiff = ( - current: DeviceDescriptor[], - descriptors: DeviceDescriptor[], -): DeviceDescriptorDiff => { - const connected = descriptors.filter(d => current.find(x => x.path === d.path) === undefined); - const disconnected = current.filter( - d => descriptors.find(x => x.path === d.path) === undefined, - ); - const changedSessions = descriptors.filter(d => { - const currentDescriptor = current.find(x => x.path === d.path); - if (currentDescriptor) { - // return currentDescriptor.debug ? (currentDescriptor.debugSession !== d.debugSession) : (currentDescriptor.session !== d.session); - return currentDescriptor.session !== d.session; - } - return false; - }); - const acquired = changedSessions.filter(d => typeof d.session === 'string'); - const released = changedSessions.filter( - d => - // const session = descriptor.debug ? descriptor.debugSession : descriptor.session; - typeof d.session !== 'string', - ); - - const changedDebugSessions = descriptors.filter(d => { - const currentDescriptor = current.find(x => x.path === d.path); - if (currentDescriptor) { - return currentDescriptor.debugSession !== d.debugSession; - } - return false; - }); - const debugAcquired = changedSessions.filter(d => typeof d.debugSession === 'string'); - const debugReleased = changedSessions.filter(d => typeof d.debugSession !== 'string'); - - const didUpdate = - connected.length + - disconnected.length + - changedSessions.length + - changedDebugSessions.length > - 0; - - return { - connected, - disconnected, - changedSessions, - acquired, - released, - changedDebugSessions, - debugAcquired, - debugReleased, - didUpdate, - descriptors, - }; -}; - -export class DescriptorStream extends EventEmitter { - // actual low-level transport, from trezor-link - transport: Transport; - - // if the transport works - listening = false; - - // if transport fetch API rejects (when computer goes to sleep) - listenTimestamp = 0; - - // null if nothing - current: DeviceDescriptor[] | null = null; - - upcoming: DeviceDescriptor[] = []; - - constructor(transport: Transport) { - super(); - this.transport = transport; - } - - // emits changes - async listen() { - // if we are not enumerating for the first time, we can let - // the transport to block until something happens - const waitForEvent = this.current !== null; - const current: DeviceDescriptor[] = this.current || []; - - this.listening = true; - - let descriptors: DeviceDescriptor[]; - try { - logger.debug('Start listening', current); - this.listenTimestamp = new Date().getTime(); - descriptors = waitForEvent - ? await this.transport.listen(current) - : await this.transport.enumerate(); - if (this.listening && !waitForEvent) { - // enumerate returns some value - // TRANSPORT.START will be emitted from DeviceList after device will be available (either acquired or unacquired) - if (descriptors.length > 0 && DataManager.getSettings('pendingTransportEvent')) { - this.emit(TRANSPORT.START_PENDING, descriptors.length); - } else { - this.emit(TRANSPORT.START); - } - } - if (!this.listening) return; // do not continue if stop() was called - - this.upcoming = descriptors; - logger.debug('Listen result', descriptors); - this._reportChanges(); - if (this.listening) this.listen(); // handlers might have called stop() - } catch (error) { - const time = new Date().getTime() - this.listenTimestamp; - logger.debug('Listen error', 'timestamp', time, typeof error); - - if (time > 1100) { - await resolveAfter(1000, null); - if (this.listening) this.listen(); - } else { - logger.warn('Transport error'); - this.emit(TRANSPORT.ERROR, error); - } - } - } - - async enumerate() { - if (!this.listening) return; - try { - this.upcoming = await this.transport.enumerate(); - this._reportChanges(); - } catch (error) { - // empty - } - } - - stop() { - this.listening = false; - this.removeAllListeners(); - } - - _reportChanges() { - const diff = getDiff(this.current || [], this.upcoming); - this.current = this.upcoming; - - if (diff.didUpdate && this.listening) { - diff.connected.forEach(d => { - this.emit(DEVICE.CONNECT, d); - }); - diff.disconnected.forEach(d => { - this.emit(DEVICE.DISCONNECT, d); - }); - diff.acquired.forEach(d => { - this.emit(DEVICE.ACQUIRED, d); - }); - diff.released.forEach(d => { - this.emit(DEVICE.RELEASED, d); - }); - diff.changedSessions.forEach(d => { - this.emit(DEVICE.CHANGED, d); - }); - this.emit(TRANSPORT.UPDATE, diff); - } - } -} diff --git a/packages/connect/src/device/Device.ts b/packages/connect/src/device/Device.ts index 0aff670635d7..47d1b08dd4e1 100644 --- a/packages/connect/src/device/Device.ts +++ b/packages/connect/src/device/Device.ts @@ -151,18 +151,21 @@ export class Device extends EventEmitter { } async acquire() { + console.log('Device.acquire'); // will be resolved after trezor-link acquire event this.deferredActions[DEVICE.ACQUIRE] = createDeferred(); this.deferredActions[DEVICE.ACQUIRED] = createDeferred(); try { - const sessionID = await this.transport.acquire( - { + const sessionID = await this.transport.acquire({ + input: { path: this.originalDescriptor.path, - // @ts-expect-error TODO: https://github.com/trezor/trezor-suite/issues/5332 previous: this.originalDescriptor.session, }, - false, - ); + // todo: meow + // first: false, + }); + console.log('Device.acquire. sessionId', sessionID); + _log.debug('Expected session id:', sessionID); this.activitySessionID = sessionID; this.deferredActions[DEVICE.ACQUIRED].resolve(); @@ -176,6 +179,7 @@ export class Device extends EventEmitter { // future defer for trezor-link release event this.deferredActions[DEVICE.RELEASE] = createDeferred(); } catch (error) { + console.log('Device.acquire: catch', error); this.deferredActions[DEVICE.ACQUIRED].resolve(); delete this.deferredActions[DEVICE.ACQUIRED]; if (this.runPromise) { @@ -188,7 +192,10 @@ export class Device extends EventEmitter { } async release() { + console.log('Device. release'); if (this.isUsedHere() && !this.keepSession && this.activitySessionID) { + console.log('Device. release this.isUsedHere()', this.isUsedHere()); + if (this.commands) { this.commands.dispose(); if (this.commands.callPromise) { @@ -200,7 +207,7 @@ export class Device extends EventEmitter { } } try { - await this.transport.release(this.activitySessionID, false, false); + await this.transport.release(this.activitySessionID, false); if (this.deferredActions[DEVICE.RELEASE]) await this.deferredActions[DEVICE.RELEASE].promise; } catch (err) { @@ -225,6 +232,7 @@ export class Device extends EventEmitter { options = parseRunOptions(options); this.runPromise = createDeferred(this._runInner.bind(this, fn, options)); + return this.runPromise.promise; } @@ -268,7 +276,10 @@ export class Device extends EventEmitter { } async _runInner(fn: (() => Promise) | undefined, options: RunOptions): Promise { + console.log('__runInner this.isUsedHere()', this.isUsedHere()); + if (!this.isUsedHere() || this.commands.disposed || !this.getExternalState()) { + console.log('__runInner acquire()'); // acquire session await this.acquire(); @@ -293,6 +304,7 @@ export class Device extends EventEmitter { ]); } } catch (error) { + console.log('_runInner catch error=', error); if (!this.inconsistent && error.message === 'GetFeatures timeout') { // handling corner-case T1 + bootloader < 1.4.0 (above) // if GetFeatures fails try again @@ -318,8 +330,15 @@ export class Device extends EventEmitter { this.keepSession = true; } + /** + * TODO TODO TODO TODO TODO: + * ignoring sessions for now + * not awaiting + */ + console.log('_runInner await this.deferredActions[DEVICE.ACQUIRE].promise;'); // wait for event from trezor-link - await this.deferredActions[DEVICE.ACQUIRE].promise; + // await this.deferredActions[DEVICE.ACQUIRE].promise; + console.log('_runInner await done'); // call inner function if (fn) { @@ -497,6 +516,17 @@ export class Device extends EventEmitter { const originalSession = this.originalDescriptor.session; const upcomingSession = upcomingDescriptor.session; + console.log( + 'Device: ', + 'updateDescriptor', + 'currentSession', + originalSession, + 'upcoming', + upcomingSession, + 'lastUsedID', + this.activitySessionID, + ); + _log.debug( 'updateDescriptor', 'currentSession', @@ -509,6 +539,7 @@ export class Device extends EventEmitter { if (!originalSession && !upcomingSession && !this.activitySessionID) { // no change + console.log('Device: updateDescriptors, return'); return; } @@ -667,7 +698,7 @@ export class Device extends EventEmitter { if (this.commands) { this.commands.cancel(); } - this.transport.release(this.activitySessionID, true, false); + this.transport.release(this.activitySessionID, true); } catch (err) { // empty } @@ -746,7 +777,7 @@ export class Device extends EventEmitter { return networkType ? ['cardano'].includes(networkType) : false; } - // + // For old bridges before 2.0.32. Doesn't work very well async legacyForceRelease() { if (this.isUsedHere()) { await this.acquire(); diff --git a/packages/connect/src/device/DeviceCommands.ts b/packages/connect/src/device/DeviceCommands.ts index f19a601e4fad..c2c12f0ba8e9 100644 --- a/packages/connect/src/device/DeviceCommands.ts +++ b/packages/connect/src/device/DeviceCommands.ts @@ -358,7 +358,11 @@ export class DeviceCommands { logger.debug('Sending', type, logMessage); try { - const promise = this.transport.call(this.sessionId, type, msg, false) as any; // TODO: https://github.com/trezor/trezor-suite/issues/5301 + const promise = this.transport.call({ + session: this.sessionId, + name: type, + data: msg, + }) as any; // TODO: https://github.com/trezor/trezor-suite/issues/5301 this.callPromise = promise; const res = await promise; const logMessage = filterForLog(res.type, res.message); @@ -396,7 +400,7 @@ export class DeviceCommands { } catch (error) { // handle possible race condition // Bridge may have some unread message in buffer, read it - await this.transport.read(this.sessionId, false); + await this.transport.receive({ session: this.sessionId }); // throw error anyway, next call should be resolved properly throw error; } @@ -691,19 +695,19 @@ export class DeviceCommands { * Bridge version =< 2.0.28 has a bug that doesn't permit it to cancel * user interactions in progress, so we have to do it manually. */ - const { activeName, version } = this.transport; - if ( - activeName && - activeName === 'BridgeTransport' && - versionCompare(version, '2.0.28') < 1 - ) { + const { name, version } = this.transport; + if (name === 'BridgeTransport' && versionCompare(version, '2.0.28') < 1) { await this.device.legacyForceRelease(); } else { - await this.transport.post(this.sessionId, 'Cancel', {}, false); + await this.transport.send({ + session: this.sessionId, + name: 'Cancel', + data: {}, + }); // post does not read back from usb stack. this means that there is a pending message left // and we need to remove it so that it does not interfere with the next transport call. // see DeviceCommands.typedCall - await this.transport.read(this.sessionId, false); + await this.transport.receive({ session: this.sessionId }); } } } diff --git a/packages/connect/src/device/DeviceList.ts b/packages/connect/src/device/DeviceList.ts index 36db50b28220..7b655b6ca755 100644 --- a/packages/connect/src/device/DeviceList.ts +++ b/packages/connect/src/device/DeviceList.ts @@ -3,27 +3,31 @@ /* eslint-disable max-classes-per-file, @typescript-eslint/no-use-before-define */ import EventEmitter from 'events'; -import TrezorLink, { +import { + BridgeTransport, + WebUsbTransport, + NodeUsbTransport, Transport, TrezorDeviceInfoWithSession as DeviceDescriptor, + // getAvailableTransport, + setFetch as setTransportFetch, } from '@trezor/transport'; + import fetch from 'cross-fetch'; +// import { getAbortController } from './AbortController'; + import { ERRORS } from '../constants'; import { TRANSPORT, DEVICE, TransportInfo } from '../events'; -import { DescriptorStream, DeviceDescriptorDiff } from './DescriptorStream'; import { Device } from './Device'; import type { Device as DeviceTyped } from '../types'; import { DataManager } from '../data/DataManager'; -import { getBridgeInfo } from '../data/transportInfo'; +// import { getBridgeInfo } from '../data/transportInfo'; import { initLog } from '../utils/debug'; import { resolveAfter } from '../utils/promiseUtils'; -import { WebUsbPlugin, ReactNativeUsbPlugin } from '../workers/workers'; -import { getAbortController } from './AbortController'; +import { ReactNativeUsbPlugin } from '../workers/workers'; import type { Controller } from './AbortController'; -const { BridgeV2, Fallback } = TrezorLink; - // custom log const _log = initLog('DeviceList'); @@ -36,10 +40,19 @@ type LowLevelPlugin = { unreadableHidDevice: boolean; // not sure }; +type DeviceDescriptorDiff = { + didUpdate: boolean; + connected: DeviceDescriptor[]; + disconnected: DeviceDescriptor[]; + changedSessions: DeviceDescriptor[]; + acquired: DeviceDescriptor[]; + released: DeviceDescriptor[]; + descriptors: DeviceDescriptor[]; +}; + interface DeviceListEvents { [TRANSPORT.START]: TransportInfo; [TRANSPORT.ERROR]: string; - [TRANSPORT.STREAM]: DescriptorStream; [DEVICE.CONNECT]: DeviceTyped; [DEVICE.CONNECT_UNACQUIRED]: DeviceTyped; [DEVICE.DISCONNECT]: DeviceTyped; @@ -60,19 +73,21 @@ export interface DeviceList { emit(type: K, args: DeviceListEvents[K]): boolean; } export class DeviceList extends EventEmitter { + // @ts-ignore transport: Transport; - transportPlugin: LowLevelPlugin | typeof undefined; + // array of transport that might be used in this environment + transports: Transport[]; - // @ts-expect-error: strictPropertyInitialization - stream: DescriptorStream; + transportPlugin: LowLevelPlugin | typeof undefined; devices: { [path: string]: Device } = {}; creatingDevicesDescriptors: { [k: string]: DeviceDescriptor } = {}; - messages: JSON | Record; + messages: JSON; + // todo: mostly ignored now transportStartPending = 0; penalizedDevices: { [deviceID: string]: number } = {}; @@ -81,61 +96,130 @@ export class DeviceList extends EventEmitter { constructor() { super(); - - const { env, webusb } = DataManager.settings; - + const { env, webusb, transports: userProvidedConfig = [] } = DataManager.settings; + // const { env } = DataManager.settings; + console.log('userProvidedConfig', userProvidedConfig); const transports: Transport[] = []; + this.messages = DataManager.getProtobufMessages(); + + const isNode = + !!process?.release?.name && process.release.name.search(/node|io\.js/) !== -1; if (env === 'react-native' && typeof ReactNativeUsbPlugin !== 'undefined') { transports.push(ReactNativeUsbPlugin()); + //} else { + // const bridgeLatestVersion = getBridgeInfo().version.join('.'); + // console.log('this messages', this.messages); + // const bridge = new BridgeTransport({ messages: this.messages }); + // // bridge.setBridgeLatestVersion(bridgeLatestVersion); + // this.fetchController = getAbortController(); + // const { signal } = this.fetchController; + // // @ts-expect-error TODO: https://github.com/trezor/trezor-suite/issues/5332 + // const fetchWithSignal = (args, options = {}) => fetch(args, { ...options, signal }); + // // detection of environment browser/node + // // todo: code should not be detecting environment itself. imho it should be built with this information passed from build process maybe? + // setTransportFetch(fetchWithSignal, isNode); + // transports.push(bridge); + } else if (isNode) { + setTransportFetch(fetch, isNode); + // transports.push(new NodeUsbTransport({ messages: this.messages })); + } else if (webusb) { + // transports.push(new WebUsbTransport({ messages: this.messages })); } else { - const bridgeLatestVersion = getBridgeInfo().version.join('.'); - const bridge = new BridgeV2(undefined, undefined); - bridge.setBridgeLatestVersion(bridgeLatestVersion); - - this.fetchController = getAbortController(); - const { signal } = this.fetchController; - // @ts-expect-error TODO: https://github.com/trezor/trezor-suite/issues/5332 - const fetchWithSignal = (args, options = {}) => fetch(args, { ...options, signal }); - - // detection of environment browser/node - // todo: code should not be detecting environment itself. imho it should be built with this information passed from build process maybe? - const isNode = - !!process?.release?.name && process.release.name.search(/node|io\.js/) !== -1; - BridgeV2.setFetch(fetchWithSignal, isNode); - // @ts-expect-error TODO: https://github.com/trezor/trezor-suite/issues/5332 - transports.push(bridge); + // maybe bridge here? } - if (webusb && typeof WebUsbPlugin !== 'undefined') { - transports.push(WebUsbPlugin()); - } + // Let user suggest transport types and order/priorty of transports. silently remove + // those we already know that can not work + + // if (isNode) { + // userProvidedConfig.splice( + // userProvidedConfig.findIndex(c => c === 'webusb'), + // 1, + // ); + // } else { + // userProvidedConfig.splice( + // userProvidedConfig.findIndex(c => c === 'nodeusb'), + // 1, + // ); + // } + + userProvidedConfig.forEach(transportType => { + if (transportType === 'nodeusb') { + transports.push(new NodeUsbTransport({ messages: this.messages })); + return; + } + if (transportType === 'bridge') { + transports.push(new BridgeTransport({ messages: this.messages })); + return; + } + if (transportType === 'webusb') { + transports.push(new WebUsbTransport({ messages: this.messages })); + return; + } + }); - this.transport = new Fallback(transports); - this.messages = DataManager.getProtobufMessages(); + this.transports = transports; } async init() { - const { transport } = this; + console.log('xxxxx DeviceList, init'); + try { _log.debug('Initializing transports'); - await transport.init(_log.enabled); - _log.debug('Configuring transports'); - await transport.configure(JSON.stringify(this.messages)); - _log.debug('Configuring transports done'); - - const { activeName } = transport; - if (activeName === 'LowlevelTransportWithSharedConnections') { - // @ts-expect-error TODO: https://github.com/trezor/trezor-suite/issues/5332 - this.transportPlugin = transport.activeTransport.plugin; + + let lastError: any = null; + if (!this.transports.length) { + throw new Error('no transports available :('); + } + for (const transport of this.transports) { + this.transport = transport; + // this.transport.on(TRANSPORT.START_PENDING, (pending: number) => { + // this.transportStartPending = pending; + // }); + + const result = await this.transport.init(); + + console.log('result', result); + + if (result.success) { + lastError = ''; + break; + } else { + lastError = result.message; + } + } + + console.log('xxxxx DeviceList init lastError=', lastError); + + if (lastError) { + throw lastError || new Error('No transport could be initialized.'); } - await this._initStream(); + this.emit(TRANSPORT.START, this.getTransportInfo()); + + // todo: maybe event should have different name + this.transport.on(TRANSPORT.UPDATE, (diff: DeviceDescriptorDiff) => { + new DiffHandler(this, diff).handle(); + }); + + this.transport.on(TRANSPORT.ERROR, error => { + console.log('DeviceList.on(TRANSPORT.ERROR, error=', error); + this.emit(TRANSPORT.ERROR, error); + // stream.stop(); + }); // listen for self emitted events and resolve pending transport event if needed this.on(DEVICE.CONNECT, this.resolveTransportEvent.bind(this)); this.on(DEVICE.CONNECT_UNACQUIRED, this.resolveTransportEvent.bind(this)); + + await this.transport.enumerate(); + + console.log('DeviceList, init, transport.listen()'); + this.transport.listen(); + console.log('xxxxx DeviceList init done'); } catch (error) { + console.log('DeviceList.init catch error=', error); this.emit(TRANSPORT.ERROR, error); } } @@ -143,7 +227,7 @@ export class DeviceList extends EventEmitter { resolveTransportEvent() { this.transportStartPending--; if (this.transportStartPending === 0) { - this.stream.emit(TRANSPORT.START); + // this.stream.emit(TRANSPORT.START); } } @@ -159,62 +243,6 @@ export class DeviceList extends EventEmitter { }); } - /** - * Transport events handler - * @param {Transport} transport - * @memberof DeviceList - */ - _initStream() { - const stream = new DescriptorStream(this.transport); - - stream.on(TRANSPORT.START_PENDING, (pending: number) => { - this.transportStartPending = pending; - }); - - stream.on(TRANSPORT.START, () => { - this.emit(TRANSPORT.START, this.getTransportInfo()); - }); - - stream.on(TRANSPORT.UPDATE, (diff: DeviceDescriptorDiff) => { - new DiffHandler(this, diff).handle(); - }); - - stream.on(TRANSPORT.ERROR, error => { - this.emit(TRANSPORT.ERROR, error); - stream.stop(); - }); - - stream.listen(); - this.stream = stream; - - if (this.transportPlugin && this.transportPlugin.name === 'WebUsbPlugin') { - const { unreadableHidDeviceChange } = this.transportPlugin; - // TODO: https://github.com/trezor/trezor-link/issues/40 - const UNREADABLE_PATH = 'unreadable'; // unreadable device doesn't return incremental path. - unreadableHidDeviceChange.on('change', () => { - if (this.transportPlugin && this.transportPlugin.unreadableHidDevice) { - const device = this._createUnreadableDevice( - { - path: UNREADABLE_PATH, - session: null, - debugSession: null, - debug: false, - }, - 'HID_DEVICE', - ); - this.devices[UNREADABLE_PATH] = device; - this.emit(DEVICE.CONNECT_UNACQUIRED, device.toMessageObject()); - } else { - const device = this.devices[UNREADABLE_PATH]; - delete this.devices[UNREADABLE_PATH]; - this.emit(DEVICE.DISCONNECT, device.toMessageObject()); - } - }); - } - - this.emit(TRANSPORT.STREAM, stream); - } - async _createAndSaveDevice(descriptor: DeviceDescriptor) { _log.debug('Creating Device', descriptor); await new CreateDeviceHandler(descriptor, this).handle(); @@ -257,8 +285,7 @@ export class DeviceList extends EventEmitter { transportType() { const { transport, transportPlugin } = this; - const { activeName } = transport; - if (activeName === 'BridgeTransport') { + if (transport.name === 'BridgeTransport') { return 'bridge'; } if (transportPlugin) { @@ -278,9 +305,9 @@ export class DeviceList extends EventEmitter { dispose() { this.removeAllListeners(); - if (this.stream) { - this.stream.stop(); - } + // if (this.stream) { + // this.stream.stop(); + // } if (this.transport) { this.transport.stop(); } @@ -300,16 +327,18 @@ export class DeviceList extends EventEmitter { } enumerate() { - this.stream.enumerate(); - if (!this.stream.current) return; + console.log('DeviceList.enumerate ==> !!! =>> !!!!'); + + // this.stream.enumerate(); + // if (!this.stream.current) return; // update current values - this.stream.current.forEach(descriptor => { - const path = descriptor.path.toString(); - const device = this.devices[path]; - if (device) { - device.updateDescriptor(descriptor); - } - }); + // this.stream.current.forEach(descriptor => { + // const path = descriptor.path.toString(); + // const device = this.devices[path]; + // if (device) { + // device.updateDescriptor(descriptor); + // } + // }); } addAuthPenalty(device: Device) { @@ -363,6 +392,8 @@ class CreateDeviceHandler { // main logic async handle() { + console.log('DeviceList:handle'); + // creatingDevicesDescriptors is needed, so that if *during* creating of Device, // other application acquires the device and changes the descriptor, // the new unacquired device has correct descriptor @@ -372,6 +403,7 @@ class CreateDeviceHandler { // "regular" device creation await this._takeAndCreateDevice(); } catch (error) { + console.log('DeviceList:handle error', error); _log.debug('Cannot create device', error); if (error.code === 'Device_NotFound') { @@ -406,9 +438,18 @@ class CreateDeviceHandler { } async _takeAndCreateDevice() { + console.log('DeviceList, _takeAndCreateDevice'); const device = Device.fromDescriptor(this.list.transport, this.descriptor); + this.list.devices[this.path] = device; - await device.run(); + console.log('DeviceList, _takeAndCreateDevice'); + const promise = device.run(); + console.log('DeviceList, _takeAndCreateDevice awaiting...'); + + await promise; + console.log('DeviceList, _takeAndCreateDevice done'); + + console.log('DeviceList, _takeAndCreateDevice. run completed, emit DEVICE.CONNECT'); this.list.emit(DEVICE.CONNECT, device.toMessageObject()); } @@ -486,10 +527,12 @@ class DiffHandler { // tries to read info about connected devices _createConnectedDevices() { + console.log('DeviceList, _createConnectedDevices'); this.diff.connected.forEach(async descriptor => { const path = descriptor.path.toString(); const priority = DataManager.getSettings('priority'); const penalty = this.list.getAuthPenalty(); + console.log('DeviceList, _createConnectedDevices, descriptor', descriptor); _log.debug('Connected', priority, penalty, descriptor.session, this.list.devices); if (priority || penalty) { await resolveAfter(501 + penalty + 100 * priority, null); @@ -497,7 +540,7 @@ class DiffHandler { if (descriptor.session == null) { await this.list._createAndSaveDevice(descriptor); } else { - const device = await this.list._createUnacquiredDevice(descriptor); + const device = this.list._createUnacquiredDevice(descriptor); this.list.devices[path] = device; this.list.emit(DEVICE.CONNECT_UNACQUIRED, device.toMessageObject()); } diff --git a/packages/connect/src/index.ts b/packages/connect/src/index.ts index fd7fd94ff9ee..2f1669e1cc02 100644 --- a/packages/connect/src/index.ts +++ b/packages/connect/src/index.ts @@ -148,8 +148,9 @@ const init = async (settings: Partial = {}) => { _core = await initCore(_settings); _core.on(CORE_EVENT, handleMessage); - + console.log('=========initTransport', settings); await initTransport(_settings); + console.log('=========initTransport done'); }; const call: CallMethod = async params => { diff --git a/packages/connect/src/types/settings.ts b/packages/connect/src/types/settings.ts index 04c4c4585b3d..97c2041f3d69 100644 --- a/packages/connect/src/types/settings.ts +++ b/packages/connect/src/types/settings.ts @@ -15,7 +15,8 @@ export interface ConnectSettings { hostIcon?: string; popup?: boolean; transportReconnect?: boolean; - webusb?: boolean; + webusb?: boolean; // deprecated + transports?: ('nodeusb' | 'bridge' | 'webusb')[]; pendingTransportEvent?: boolean; lazyLoad?: boolean; interactionTimeout?: number; diff --git a/packages/connect/src/workers/workers-browser.ts b/packages/connect/src/workers/workers-browser.ts index f15b31455dcc..96ee5e78d5c4 100644 --- a/packages/connect/src/workers/workers-browser.ts +++ b/packages/connect/src/workers/workers-browser.ts @@ -1,26 +1,8 @@ -import SharedConnectionWorker from '@trezor/transport/lib/lowlevel/sharedConnectionWorker'; import BlockbookWorker from '@trezor/blockchain-link/lib/workers/blockbook'; import RippleWorker from '@trezor/blockchain-link/lib/workers/ripple'; import BlockfrostWorker from '@trezor/blockchain-link/lib/workers/blockfrost'; -import TrezorLink from '@trezor/transport'; - -const WebUsbPlugin = () => - new TrezorLink.Lowlevel( - // @ts-expect-error TODO: https://github.com/trezor/trezor-suite/issues/5332 - new TrezorLink.WebUsb(), - // @ts-expect-error TODO: https://github.com/trezor/trezor-suite/issues/5332 - typeof SharedWorker !== 'undefined' ? () => new SharedConnectionWorker() : null, - ); - const ReactNativeUsbPlugin = undefined; const ElectrumWorker = undefined; -export { - WebUsbPlugin, - ReactNativeUsbPlugin, - BlockbookWorker, - RippleWorker, - BlockfrostWorker, - ElectrumWorker, -}; +export { ReactNativeUsbPlugin, BlockbookWorker, RippleWorker, BlockfrostWorker, ElectrumWorker }; diff --git a/packages/connect/src/workers/workers-react-native.ts b/packages/connect/src/workers/workers-react-native.ts index b6c4cf4ce0da..7042c3533335 100644 --- a/packages/connect/src/workers/workers-react-native.ts +++ b/packages/connect/src/workers/workers-react-native.ts @@ -1,17 +1,17 @@ import BlockbookWorker from '@trezor/blockchain-link/lib/workers/blockbook'; import RippleWorker from '@trezor/blockchain-link/lib/workers/ripple'; import BlockfrostWorker from '@trezor/blockchain-link/lib/workers/blockfrost'; -import TrezorLink from '@trezor/transport'; -import { ReactNativePlugin } from './RNUsbPlugin'; +// import TrezorLink from '@trezor/transport'; +// import { ReactNativePlugin } from './RNUsbPlugin'; const WebUsbPlugin = undefined; const ElectrumWorker = undefined; -const ReactNativeUsbPlugin = () => new TrezorLink.Lowlevel(new ReactNativePlugin(), undefined); +// const ReactNativeUsbPlugin = () => new TrezorLink.Lowlevel(new ReactNativePlugin(), undefined); export { WebUsbPlugin, - ReactNativeUsbPlugin, + // ReactNativeUsbPlugin, BlockbookWorker, RippleWorker, BlockfrostWorker, diff --git a/packages/suite-desktop/src/modules/index.ts b/packages/suite-desktop/src/modules/index.ts index e69bdab8db0c..140acea0f81f 100644 --- a/packages/suite-desktop/src/modules/index.ts +++ b/packages/suite-desktop/src/modules/index.ts @@ -24,7 +24,7 @@ const MODULES = [ 'theme', 'http-receiver', 'metadata', - 'bridge', + // 'bridge', 'custom-protocols', 'auto-updater', 'store', diff --git a/packages/suite-web/e2e/tests/onboarding/transport.test.ts b/packages/suite-web/e2e/tests/onboarding/transport.test.ts index a650279fdf4d..5d475d50938b 100644 --- a/packages/suite-web/e2e/tests/onboarding/transport.test.ts +++ b/packages/suite-web/e2e/tests/onboarding/transport.test.ts @@ -13,7 +13,7 @@ describe('Onboarding - transport webusb/bridge', () => { .its('store') .invoke('getState') .should(state => { - expect(state?.suite.transport?.type).to.deep.eq('WebUsbPlugin'); + expect(state?.suite.transport?.type).to.deep.eq('WebusbTransport'); }); cy.getTestElement('@onboarding/expand-troubleshooting-tips').click(); diff --git a/packages/suite/src/components/suite/Preloader/__tests__/Preloader.test.tsx b/packages/suite/src/components/suite/Preloader/__tests__/Preloader.test.tsx index 7a64a0f36cf3..af2bdff0aee4 100644 --- a/packages/suite/src/components/suite/Preloader/__tests__/Preloader.test.tsx +++ b/packages/suite/src/components/suite/Preloader/__tests__/Preloader.test.tsx @@ -163,7 +163,7 @@ describe('Preloader component', () => { const store = initStore( getInitialState({ suite: { - transport: { type: 'WebUsbPlugin' }, + transport: { type: 'WebusbTransport' }, }, }), ); @@ -196,7 +196,7 @@ describe('Preloader component', () => { const store = initStore( getInitialState({ suite: { - transport: { type: 'WebUsbPlugin' }, + transport: { type: 'WebUsbTransport' }, device: { type: 'unreadable', error: 'LIBUSB_ERROR_ACCESS' }, }, }), diff --git a/packages/suite/src/support/extraDependencies.ts b/packages/suite/src/support/extraDependencies.ts index dd9a8f9ea2ea..45ecad02bbf6 100644 --- a/packages/suite/src/support/extraDependencies.ts +++ b/packages/suite/src/support/extraDependencies.ts @@ -16,17 +16,29 @@ import { selectIsPendingTransportEvent } from '@suite-reducers/deviceReducer'; import * as suiteActions from '../actions/suite/suiteActions'; import { isWeb } from '@suite-utils/env'; import { resolveStaticPath } from '@trezor/utils'; +import { ConnectSettings } from 'packages/connect/lib'; const connectSrc = resolveStaticPath('connect/'); // 'https://localhost:8088/'; // 'https://connect.corp.sldev.cz/develop/'; +// todo: for future, I believe it would be useful to define type and priority of transport from outside +// at the same time, connect should provide reasonable defaults eg. web: [bridge, webusb]... +const transports: ConnectSettings['transports'] = []; +if (isWeb()) { + transports.push('bridge'); + transports.push('webusb'); +} else { + transports.push('nodeusb'); +} + const connectInitSettings = { connectSrc, transportReconnect: true, debug: false, popup: false, - webusb: isWeb(), + webusb: isWeb(), // deprecated, use transports + transports, manifest: { email: 'info@trezor.io', appUrl: '@trezor/suite', diff --git a/packages/suite/src/utils/suite/__fixtures__/messageSystem.ts b/packages/suite/src/utils/suite/__fixtures__/messageSystem.ts index 750e25e37964..0317527ba222 100644 --- a/packages/suite/src/utils/suite/__fixtures__/messageSystem.ts +++ b/packages/suite/src/utils/suite/__fixtures__/messageSystem.ts @@ -549,7 +549,7 @@ export const validateTransportCompatibility = [ webusbplugin: '2', }, transport: { - type: 'WebUsbPlugin', + type: 'WebusbTransport', version: '2.0.0', }, result: true, @@ -561,7 +561,7 @@ export const validateTransportCompatibility = [ webusbplugin: '1.9.2', }, transport: { - type: 'WebUsbPlugin', + type: 'WebusbTransport', version: '1.9.3', }, result: false, diff --git a/packages/suite/src/utils/suite/__tests__/transport.test.ts b/packages/suite/src/utils/suite/__tests__/transport.test.ts index 6293059c3cdb..68d4b32fb227 100644 --- a/packages/suite/src/utils/suite/__tests__/transport.test.ts +++ b/packages/suite/src/utils/suite/__tests__/transport.test.ts @@ -4,7 +4,7 @@ const fixtures = [ { description: `Transport is webusb`, transport: { - type: 'WebUsbPlugin', + type: 'WebusbTransport', }, result: true, }, diff --git a/packages/suite/src/utils/suite/transport.ts b/packages/suite/src/utils/suite/transport.ts index 07b140d1fb7a..c1cf0309eb28 100644 --- a/packages/suite/src/utils/suite/transport.ts +++ b/packages/suite/src/utils/suite/transport.ts @@ -1,4 +1,4 @@ import { AppState } from '@suite-types'; export const isWebUsb = (transport?: AppState['suite']['transport']) => - !!(transport && transport.type && transport.type === 'WebUsbPlugin'); + !!(transport && transport.type && transport.type === 'WebusbTransport'); diff --git a/packages/transport/e2e/fixtures/api.ts b/packages/transport/e2e/fixtures/api.ts new file mode 100644 index 000000000000..ce8f0988199f --- /dev/null +++ b/packages/transport/e2e/fixtures/api.ts @@ -0,0 +1,40 @@ +// @ts-nocheck +import { Transport } from '../../src'; + +type Fixture any> = { + description: string; + in: Parameters; + out: { + bridge: Awaited>; + nodeusb: Awaited>; + }; +}; + +const enumerate: Fixture[] = [ + { + description: 'my connected device', + in: [], + out: { + // @ts-expect-error + BridgeTransport: [{ path: '1', product: 21441, session: null, vendor: 4617 }], + NodeUsbTransport: [{ path: '0A798C90E3EBD2ACE9739607' }], + }, + }, +]; + +const call: Fixture[] = [ + { + description: 'my connected device', + in: [{ session: '1', name: 'GetFeatures', data: {} }], + out: { + // @ts-expect-error + BridgeTransport: [{ path: '1', product: 21441, session: null, vendor: 4617 }], + NodeUsbTransport: [{ path: '0A798C90E3EBD2ACE9739607' }], + }, + }, +]; + +export const fixtures = { + // enumerate, + call, +}; diff --git a/packages/transport/e2e/tests/api.meow.ts b/packages/transport/e2e/tests/api.meow.ts new file mode 100644 index 000000000000..1e3a1b31dc83 --- /dev/null +++ b/packages/transport/e2e/tests/api.meow.ts @@ -0,0 +1,50 @@ +// !!!!! dont forget testEnvironment: node otherwise it returns weird results +// todo resolve ts + +import fetch from 'node-fetch'; +import { BridgeTransport, setFetch } from '../../lib'; +import { NodeUsbTransport } from '../../lib/transports/nodeusb'; + +import * as messages from '../../messages.json'; + +import { fixtures } from '../fixtures/api'; + +setFetch(fetch, true); + +describe(NodeUsbTransport.name, () => { + const Transport = NodeUsbTransport; + Object.keys(fixtures).forEach(method => { + // @ts-ignore + fixtures[method].forEach(f => { + test(`${method}: ${f.description}`, async () => { + const transport = new Transport({ + messages, + }); + await transport.init(); + + // @ts-ignore + const result = await transport[method](...f.in); + expect(result).toEqual(f.out[Transport.name]); + }); + }); + }); +}); + +describe(BridgeTransport.name, () => { + const Transport = BridgeTransport; + Object.keys(fixtures).forEach(method => { + // @ts-ignore + fixtures[method].forEach(f => { + test(`${method}: ${f.description}`, async () => { + const transport = new Transport({ + messages, + }); + await transport.init(); + + // @ts-ignore + const result = await transport[method](...f.in); + expect(result).toEqual(f.out[Transport.name]); + }); + }); + }); +}); diff --git a/packages/transport/e2e/tests/api2.meow.ts b/packages/transport/e2e/tests/api2.meow.ts new file mode 100644 index 000000000000..10479ae58456 --- /dev/null +++ b/packages/transport/e2e/tests/api2.meow.ts @@ -0,0 +1,166 @@ +// @ts-nocheck + +import fetch from 'node-fetch'; +// import { Transport } from 'packages/transport/src'; +import { TrezorUserEnvLink } from '@trezor/trezor-user-env-link'; + +// testing build. yarn workspace @trezor/transport build:lib is a required step therefore +import { BridgeTransport, setFetch } from '../../lib'; +import { NodeUsbTransport } from '../../lib/transports/nodeusb'; + +import * as messages from '../../messages.json'; + +jest.setTimeout(60000); + +const mnemonicAll = 'all all all all all all all all all all all all'; + +const emulatorSetupOpts = { + mnemonic: mnemonicAll, + pin: '', + passphrase_protection: false, + label: 'TrezorT', + needs_backup: true, +}; + +const emulatorStartOpts = { version: '2-master', wipe: true }; + +const enumerate = { + BridgeTransport: [ + { + path: '1', + session: null, + product: 0, + vendor: 0, + }, + ], + NodeUsbTransport: [{ path: '0A798C90E3EBD2ACE9739607' }], +}; + +describe('api tests', () => { + beforeAll(async () => { + // await TrezorUserEnvLink.connect(); + }); + + afterAll(() => { + // TrezorUserEnvLink.api.stopEmu(); + // TrezorUserEnvLink.disconnect(); + }); + + test.each[(NodeUsbTransport, BridgeTransport)]('%s: test', () => { + let devices: any[]; + let session: any; + let transport: Transport; + beforeEach(async () => { + // await TrezorUserEnvLink.send({ type: 'bridge-stop' }); + // await TrezorUserEnvLink.send({ type: 'emulator-start', ...emulatorStartOpts }); + // await TrezorUserEnvLink.send({ type: 'emulator-setup', ...emulatorSetupOpts }); + // await TrezorUserEnvLink.send({ type: 'bridge-start' }); + + setFetch(fetch, true); + + // @ts-ignore + transport = new Transport({ messages: messages }); + + await transport.init(); + + devices = await transport.enumerate(); + + // @ts-ignore + expect(devices).toEqual(enumerate[Transport.name]); + + session = await transport.acquire({ input: { path: devices[0].path } }); + }); + + test(`Call(GetFeatures)`, async () => { + const message = await transport.call({ session, name: 'GetFeatures', data: {} }); + expect(message).toMatchObject({ + type: 'Features', + message: { + vendor: 'trezor.io', + }, + }); + }); + + // test(`send(GetFeatures) - receive`, async () => { + // const sendResponse = await bridge.send({ session, name: 'GetFeatures', data: {} }); + // expect(sendResponse).toEqual(undefined); + + // const receiveResponse = await bridge.receive({ session }); + // expect(receiveResponse).toMatchObject({ + // type: 'Features', + // message: { + // vendor: 'trezor.io', + // label: 'TrezorT', + // }, + // }); + // }); + + // test(`call(ChangePin) - send(Cancel) - receive`, async () => { + // // initiate change pin procedure on device + // const callResponse = await bridge.call({ session, name: 'ChangePin', data: {} }); + // expect(callResponse).toMatchObject({ + // type: 'ButtonRequest', + // }); + + // // cancel change pin procedure + // const sendResponse = await bridge.send({ session, name: 'Cancel', data: {} }); + // expect(sendResponse).toEqual(undefined); + + // // receive response + // const receiveResponse = await bridge.receive({ session }); + // expect(receiveResponse).toMatchObject({ + // type: 'Failure', + // message: { + // code: 'Failure_ActionCancelled', + // message: 'Cancelled', + // }, + // }); + + // // validate that we can continue with communication + // const message = await bridge.call({ + // session, + // name: 'GetFeatures', + // data: {}, + // }); + // expect(message).toMatchObject({ + // type: 'Features', + // message: { + // vendor: 'trezor.io', + // label: 'TrezorT', + // }, + // }); + // }); + + // test(`call(Backup) - send(Cancel) - receive`, async () => { + // // initiate change pin procedure on device + // const callResponse = await bridge.call({ session, name: 'BackupDevice', data: {} }); + // expect(callResponse).toMatchObject({ + // type: 'ButtonRequest', + // }); + + // // cancel change pin procedure + // const sendResponse = await bridge.send({ session, name: 'Cancel', data: {} }); + // expect(sendResponse).toEqual(undefined); + + // // receive response + // const receiveResponse = await bridge.receive({ session }); + // expect(receiveResponse).toMatchObject({ + // type: 'Failure', + // message: { + // code: 'Failure_ActionCancelled', + // message: 'Cancelled', + // }, + // }); + + // // validate that we can continue with communication + // const message = await bridge.call({ session, name: 'GetFeatures', data: {} }); + // expect(message).toMatchObject({ + // type: 'Features', + // message: { + // vendor: 'trezor.io', + // label: 'TrezorT', + // }, + // }); + // }); + }); +}); diff --git a/packages/transport/e2e/tests/bridge.test.ts b/packages/transport/e2e/tests/bridge.test.ts index 42f5ca316eaa..a6d2cdb74237 100644 --- a/packages/transport/e2e/tests/bridge.test.ts +++ b/packages/transport/e2e/tests/bridge.test.ts @@ -1,13 +1,10 @@ import fetch from 'node-fetch'; +import { TrezorUserEnvLink } from '@trezor/trezor-user-env-link'; // testing build. yarn workspace @trezor/transport build:lib is a required step therefore -import TrezorLink from '../../lib'; +import { BridgeTransport, setFetch } from '../../lib'; import messages from '../../messages.json'; -import { TrezorUserEnvLink } from '@trezor/trezor-user-env-link'; - -const { BridgeV2 } = TrezorLink; - // todo: introduce global jest config for e2e jest.setTimeout(60000); @@ -41,19 +38,17 @@ describe('bridge', () => { let session: any; beforeEach(async () => { await TrezorUserEnvLink.send({ type: 'bridge-stop' }); + + // TODO: we cant see emulator in docker yet await TrezorUserEnvLink.send({ type: 'emulator-start', ...emulatorStartOpts }); await TrezorUserEnvLink.send({ type: 'emulator-setup', ...emulatorSetupOpts }); await TrezorUserEnvLink.send({ type: 'bridge-start', version: bridgeVersion }); - BridgeV2.setFetch(fetch, true); + setFetch(fetch, true); - bridge = new BridgeV2(undefined, undefined); + bridge = new BridgeTransport({ messages: messages }); - // this is how @trezor/connect is using it at the moment - // bridge.setBridgeLatestVersion(bridgeVersion); - - await bridge.init(false); - bridge.configure(messages); + await bridge.init(); devices = await bridge.enumerate(); @@ -61,18 +56,19 @@ describe('bridge', () => { { path: '1', session: null, - debugSession: null, product: 0, vendor: 0, + // we don't use it but bridge returns debug: true, + debugSession: null, }, ]); - session = await bridge.acquire({ path: devices[0].path }, false); + session = await bridge.acquire({ input: { path: devices[0].path } }); }); test(`Call(GetFeatures)`, async () => { - const message = await bridge.call(session, 'GetFeatures', {}, false); + const message = await bridge.call({ session, name: 'GetFeatures', data: {} }); expect(message).toMatchObject({ type: 'Features', message: { @@ -82,12 +78,12 @@ describe('bridge', () => { }); }); - test(`post(GetFeatures) - read`, async () => { - const postResponse = await bridge.post(session, 'GetFeatures', {}, false); - expect(postResponse).toEqual(undefined); + test(`send(GetFeatures) - receive`, async () => { + const sendResponse = await bridge.send({ session, name: 'GetFeatures', data: {} }); + expect(sendResponse).toEqual(undefined); - const readResponse = await bridge.read(session, false); - expect(readResponse).toMatchObject({ + const receiveResponse = await bridge.receive({ session }); + expect(receiveResponse).toMatchObject({ type: 'Features', message: { vendor: 'trezor.io', @@ -96,20 +92,20 @@ describe('bridge', () => { }); }); - test(`call(ChangePin) - post(Cancel) - read`, async () => { + test(`call(ChangePin) - send(Cancel) - receive`, async () => { // initiate change pin procedure on device - const callResponse = await bridge.call(session, 'ChangePin', {}, false); + const callResponse = await bridge.call({ session, name: 'ChangePin', data: {} }); expect(callResponse).toMatchObject({ type: 'ButtonRequest', }); // cancel change pin procedure - const postResponse = await bridge.post(session, 'Cancel', {}, false); - expect(postResponse).toEqual(undefined); + const sendResponse = await bridge.send({ session, name: 'Cancel', data: {} }); + expect(sendResponse).toEqual(undefined); - // read response - const readResponse = await bridge.read(session, false); - expect(readResponse).toMatchObject({ + // receive response + const receiveResponse = await bridge.receive({ session }); + expect(receiveResponse).toMatchObject({ type: 'Failure', message: { code: 'Failure_ActionCancelled', @@ -118,7 +114,11 @@ describe('bridge', () => { }); // validate that we can continue with communication - const message = await bridge.call(session, 'GetFeatures', {}, false); + const message = await bridge.call({ + session, + name: 'GetFeatures', + data: {}, + }); expect(message).toMatchObject({ type: 'Features', message: { @@ -128,20 +128,20 @@ describe('bridge', () => { }); }); - test(`call(Backup) - post(Cancel) - read`, async () => { + test(`call(Backup) - send(Cancel) - receive`, async () => { // initiate change pin procedure on device - const callResponse = await bridge.call(session, 'BackupDevice', {}, false); + const callResponse = await bridge.call({ session, name: 'BackupDevice', data: {} }); expect(callResponse).toMatchObject({ type: 'ButtonRequest', }); // cancel change pin procedure - const postResponse = await bridge.post(session, 'Cancel', {}, false); - expect(postResponse).toEqual(undefined); + const sendResponse = await bridge.send({ session, name: 'Cancel', data: {} }); + expect(sendResponse).toEqual(undefined); - // read response - const readResponse = await bridge.read(session, false); - expect(readResponse).toMatchObject({ + // receive response + const receiveResponse = await bridge.receive({ session }); + expect(receiveResponse).toMatchObject({ type: 'Failure', message: { code: 'Failure_ActionCancelled', @@ -150,7 +150,7 @@ describe('bridge', () => { }); // validate that we can continue with communication - const message = await bridge.call(session, 'GetFeatures', {}, false); + const message = await bridge.call({ session, name: 'GetFeatures', data: {} }); expect(message).toMatchObject({ type: 'Features', message: { diff --git a/packages/transport/e2e/tests/nodeusb.meow.ts b/packages/transport/e2e/tests/nodeusb.meow.ts new file mode 100644 index 000000000000..0dbd7fc068ad --- /dev/null +++ b/packages/transport/e2e/tests/nodeusb.meow.ts @@ -0,0 +1,113 @@ +import { TrezorUserEnvLink } from '@trezor/trezor-user-env-link'; + +import { NodeUsbTransport } from '../../lib/transports/nodeusb'; +import messages from '../../messages.json'; + +// todo: introduce global jest config for e2e +jest.setTimeout(60000); + +const mnemonicAll = 'all all all all all all all all all all all all'; + +const emulatorSetupOpts = { + mnemonic: mnemonicAll, + pin: '', + passphrase_protection: false, + label: 'TrezorT', + needs_backup: true, +}; + +const emulatorStartOpts = { version: '2-master', wipe: true }; + +describe('nodeusb', () => { + beforeAll(async () => { + await TrezorUserEnvLink.connect(); + }); + + afterAll(() => { + TrezorUserEnvLink.disconnect(); + }); + + let transport: any; + let devices: any[]; + let session: any; + beforeEach(async () => { + await TrezorUserEnvLink.send({ type: 'bridge-stop' }); + await TrezorUserEnvLink.send({ type: 'emulator-start', ...emulatorStartOpts }); + await TrezorUserEnvLink.send({ type: 'emulator-setup', ...emulatorSetupOpts }); + + transport = new NodeUsbTransport({ messages: messages }); + + await transport.init(); + + devices = await transport.enumerate(); + + expect(devices).toEqual([ + { + // TODO: different from bridge!!! + path: '0A798C90E3EBD2ACE9739607', + }, + ]); + + session = await transport.acquire({ input: { path: devices[0].path } }); + }); + + test(`Call(GetFeatures)`, async () => { + const message = await transport.call({ session, name: 'GetFeatures', data: {} }); + expect(message).toMatchObject({ + type: 'Features', + message: { + vendor: 'trezor.io', + }, + }); + }); + + test(`send(GetFeatures) - receive`, async () => { + const sendResponse = await transport.send({ session, name: 'GetFeatures', data: {} }); + expect(sendResponse).toEqual(undefined); + + const receiveResponse = await transport.receive({ session }); + expect(receiveResponse).toMatchObject({ + type: 'Features', + message: { + vendor: 'trezor.io', + }, + }); + }); + + test(`call(ChangePin) - send(Cancel) - receive`, async () => { + // initiate change pin procedure on device + const callResponse = await transport.call({ session, name: 'ChangePin', data: {} }); + expect(callResponse).toMatchObject({ + type: 'ButtonRequest', + }); + + // cancel change pin procedure + const sendResponse = await transport.send({ session, name: 'Cancel', data: {} }); + expect(sendResponse).toEqual(undefined); + + // receive response + const receiveResponse = await transport.receive({ session }); + expect(receiveResponse).toMatchObject({ + type: 'Failure', + message: { + code: 'Failure_ActionCancelled', + // TODO: different message than in bridge!!!! + // message: 'Cancelled', + message: 'Action cancelled by user', + }, + }); + + // validate that we can continue with communication + const message = await transport.call({ + session, + name: 'GetFeatures', + data: {}, + }); + expect(message).toMatchObject({ + type: 'Features', + message: { + vendor: 'trezor.io', + }, + }); + }); +}); diff --git a/packages/transport/jest.config.e2e.js b/packages/transport/jest.config.e2e.js new file mode 100644 index 000000000000..0d4e8107f314 --- /dev/null +++ b/packages/transport/jest.config.e2e.js @@ -0,0 +1,6 @@ +module.exports = { + preset: '../../jest.config.base.js', + testEnvironment: 'node', + testMatch: ['**/e2e/tests/*.test.ts'], + modulePathIgnorePatterns: ['node_modules', '/lib', '/libDev'], +}; diff --git a/packages/transport/jest.config.js b/packages/transport/jest.config.js index c440e90e8c16..6f09a715cab6 100644 --- a/packages/transport/jest.config.js +++ b/packages/transport/jest.config.js @@ -1,6 +1,5 @@ -/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ module.exports = { - preset: 'ts-jest', + preset: '../../jest.config.base.js', testEnvironment: 'node', testMatch: ['**/tests/*.test.ts'], modulePathIgnorePatterns: ['node_modules', '/lib', '/libDev'], diff --git a/packages/transport/package.json b/packages/transport/package.json index c1be64420d06..b66881a59796 100644 --- a/packages/transport/package.json +++ b/packages/transport/package.json @@ -15,6 +15,10 @@ "transport" ], "main": "./lib/index.js", + "browser": { + "./lib/transports/nodeusb": "./lib/transports/nodeusb.browser", + "./lib/transports/webusb": "./lib/transports/webusb.browser" + }, "files": [ "lib/", "!**/*.map", @@ -29,7 +33,7 @@ "build:lib": "rimraf -rf lib && yarn tsc --build ./tsconfig.lib.json", "publish:lib": "./scripts/publish-lib.sh", "test:unit": "jest", - "test:e2e": "yarn jest --verbose --testPathPattern e2e/ --runInBand -c ../../jest.config.base.js", + "test:e2e": "yarn jest --verbose --runInBand -c ./jest.config.e2e.js", "example:bridge": "jest --verbose -c jest.config.e2e.js --testPathPattern bridge.integration", "update:protobuf": "./scripts/protobuf-build.sh && yarn prettier --write \"{messages.json,src/types/messages.ts}\"" }, @@ -47,6 +51,7 @@ "json-stable-stringify": "^1.0.1", "long": "^4.0.0", "prettier": "^2.7.1", - "protobufjs": "^6.11.3" + "protobufjs": "^6.11.3", + "usb": "^2.5.2" } } diff --git a/packages/transport/src/bridge/v2.ts b/packages/transport/src/bridge/v2.ts deleted file mode 100644 index 88bb91f81221..000000000000 --- a/packages/transport/src/bridge/v2.ts +++ /dev/null @@ -1,184 +0,0 @@ -// bridge v2 is half-way between lowlevel and not -// however, it is not doing actual sending in/to the devices -// and it refers enumerate to bridge -import { versionUtils } from '@trezor/utils'; - -import { request as http, setFetch as rSetFetch } from './http'; -import * as check from '../utils/highlevel-checks'; -import { buildOne } from '../lowlevel/send'; -import { parseConfigure } from '../lowlevel/protobuf/messages'; -import { receiveOne } from '../lowlevel/receive'; -import { DEFAULT_URL, DEFAULT_VERSION_URL } from '../config'; -import type { INamespace } from 'protobufjs/light'; -import type { AcquireInput, TrezorDeviceInfoWithSession } from '../types'; - -type IncompleteRequestOptions = { - body?: Array | Record | string; - url: string; -}; - -export default class BridgeTransport { - _messages: ReturnType | undefined; - bridgeVersion?: string; - configured = false; - debug = false; - isOutdated?: boolean; - name = 'BridgeTransport'; - newestVersionUrl: string; - requestNeeded = false; - stopped = false; - url: string; - version = ''; - - constructor(url?: string, newestVersionUrl?: string) { - this.url = url == null ? DEFAULT_URL : url; - this.newestVersionUrl = newestVersionUrl == null ? DEFAULT_VERSION_URL : newestVersionUrl; - } - - _post(options: IncompleteRequestOptions) { - if (this.stopped) { - // eslint-disable-next-line prefer-promise-reject-errors - return Promise.reject('Transport stopped.'); - } - return http({ - ...options, - method: 'POST', - url: this.url + options.url, - skipContentTypeHeader: true, - }); - } - - async init(debug: boolean) { - this.debug = !!debug; - await this._silentInit(); - } - - async _silentInit() { - const infoS = await http({ - url: this.url, - method: 'POST', - }); - const info = check.info(infoS); - this.version = info.version; - const newVersion = - typeof this.bridgeVersion === 'string' - ? this.bridgeVersion - : check.version( - await http({ - url: `${this.newestVersionUrl}?${Date.now()}`, - method: 'GET', - }), - ); - this.isOutdated = versionUtils.isNewer(newVersion, this.version); - } - - configure(signedData: INamespace) { - const messages = parseConfigure(signedData); - this.configured = true; - this._messages = messages; - } - - async listen(old?: Array) { - if (old == null) { - throw new Error('Bridge v2 does not support listen without previous.'); - } - const devicesS = await this._post({ - url: '/listen', - body: old, - }); - const devices = check.devices(devicesS); - return devices; - } - - async enumerate() { - const devicesS = await this._post({ url: '/enumerate' }); - const devices = check.devices(devicesS); - return devices; - } - - _acquireMixed(input: AcquireInput, debugLink: boolean) { - const previousStr = input.previous == null ? 'null' : input.previous; - const url = `${debugLink ? '/debug' : ''}/acquire/${input.path}/${previousStr}`; - return this._post({ url }); - } - - async acquire(input: AcquireInput, debugLink: boolean) { - const acquireS = await this._acquireMixed(input, debugLink); - return check.acquire(acquireS); - } - - async release(session: string, onclose: boolean, debugLink: boolean) { - const res = this._post({ - url: `${debugLink ? '/debug' : ''}/release/${session}`, - }); - if (onclose) { - return; - } - await res; - } - - async call(session: string, name: string, data: Record, debugLink: boolean) { - if (this._messages == null) { - throw new Error('Transport not configured.'); - } - const messages = this._messages; - const o = buildOne(messages, name, data); - const outData = o.toString('hex'); - const resData = await this._post({ - url: `${debugLink ? '/debug' : ''}/call/${session}`, - body: outData, - }); - if (typeof resData !== 'string') { - throw new Error('Returning data is not string.'); - } - const jsonData = receiveOne(messages, resData); - return check.call(jsonData); - } - - async post(session: string, name: string, data: Record, debugLink: boolean) { - if (this._messages == null) { - throw new Error('Transport not configured.'); - } - const messages = this._messages; - const outData = buildOne(messages, name, data).toString('hex'); - await this._post({ - url: `${debugLink ? '/debug' : ''}/post/${session}`, - body: outData, - }); - } - - async read(session: string, debugLink: boolean) { - if (this._messages == null) { - throw new Error('Transport not configured.'); - } - const messages = this._messages; - const resData = await this._post({ - url: `${debugLink ? '/debug' : ''}/read/${session}`, - }); - if (typeof resData !== 'string') { - throw new Error('Returning data is not string.'); - } - const jsonData = receiveOne(messages, resData); - return check.call(jsonData); - } - - static setFetch(fetch: any, isNode?: boolean) { - rSetFetch(fetch, isNode); - } - - requestDevice() { - return Promise.reject(); - } - - setBridgeLatestUrl(url: string) { - this.newestVersionUrl = url; - } - - setBridgeLatestVersion(version: string) { - this.bridgeVersion = version; - } - - stop() { - this.stopped = true; - } -} diff --git a/packages/transport/src/constants.ts b/packages/transport/src/constants.ts new file mode 100644 index 000000000000..d95b547b1325 --- /dev/null +++ b/packages/transport/src/constants.ts @@ -0,0 +1,22 @@ +export const DEFAULT_URL = 'http://127.0.0.1:21325'; +export const DEFAULT_VERSION_URL = 'https://connect.trezor.io/8/data/bridge/latest.txt'; +export const MESSAGE_HEADER_BYTE = 0x23; +export const HEADER_SIZE = 1 + 1 + 4 + 2; +export const BUFFER_SIZE = 63; + +export const TREZOR_DESCS = [ + // TREZOR v1 + // won't get opened, but we can show error at least + { vendorId: 0x534c, productId: 0x0001 }, + // TREZOR webusb Bootloader + { vendorId: 0x1209, productId: 0x53c0 }, + // TREZOR webusb Firmware + { vendorId: 0x1209, productId: 0x53c1 }, +]; + +export const T1HID_VENDOR = 0x534c; +export const CONFIGURATION_ID = 1; +export const INTERFACE_ID = 0; +export const ENDPOINT_ID = 1; +export const DEBUG_INTERFACE_ID = 1; +export const DEBUG_ENDPOINT_ID = 2; diff --git a/packages/transport/src/fallback.ts b/packages/transport/src/fallback.ts deleted file mode 100644 index 8eae5f527fbf..000000000000 --- a/packages/transport/src/fallback.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { Transport, AcquireInput, TrezorDeviceInfoWithSession } from './types'; - -export default class FallbackTransport { - _availableTransports: Array = []; - activeName = ''; - // @ts-expect-error - activeTransport: Transport; - configured = false; - debug = false; - isOutdated = false; - name = 'FallbackTransport'; - requestNeeded = false; - transports: Array = []; - version = ''; - - constructor(transports: Array) { - this.transports = transports; - } - - // first one that inits successfully is the final one; others won't even start initiating - async _tryInitTransports() { - const res: Array = []; - let lastError: any = null; - for (const transport of this.transports) { - try { - await transport.init(this.debug); - res.push(transport); - } catch (e) { - lastError = e; - } - } - if (res.length === 0) { - throw lastError || new Error('No transport could be initialized.'); - } - return res; - } - - // first one that inits successfully is the final one; others won't even start initing - async _tryConfigureTransports(data: JSON | string) { - let lastError: any = null; - for (const transport of this._availableTransports) { - try { - await transport.configure(data); - return transport; - } catch (e) { - lastError = e; - } - } - throw lastError || new Error('No transport could be initialized.'); - } - - async init(debug?: boolean) { - this.debug = !!debug; - - // init ALL OF THEM - const transports = await this._tryInitTransports(); - this._availableTransports = transports; - - // a slight hack - configured is always false, so we force caller to call configure() - // to find out the actual working transport (bridge falls on configure, not on info) - this.version = transports[0].version; - this.configured = false; - } - - async configure(signedData: JSON | string) { - const pt: Promise = this._tryConfigureTransports(signedData); - this.activeTransport = await pt; - this.configured = this.activeTransport.configured; - this.version = this.activeTransport.version; - this.activeName = this.activeTransport.name; - this.requestNeeded = this.activeTransport.requestNeeded; - this.isOutdated = this.activeTransport.isOutdated; - } - - enumerate() { - return this.activeTransport.enumerate(); - } - - listen(old?: Array) { - return this.activeTransport.listen(old); - } - - acquire(input: AcquireInput, debugLink: boolean) { - return this.activeTransport.acquire(input, debugLink); - } - - release(session: string, onclose: boolean, debugLink: boolean) { - return this.activeTransport.release(session, onclose, debugLink); - } - - call(session: string, name: string, data: Record, debugLink: boolean) { - return this.activeTransport.call(session, name, data, debugLink); - } - - post(session: string, name: string, data: Record, debugLink: boolean) { - return this.activeTransport.post(session, name, data, debugLink); - } - - read(session: string, debugLink: boolean) { - return this.activeTransport.read(session, debugLink); - } - - requestDevice() { - return this.activeTransport.requestDevice(); - } - - setBridgeLatestUrl(url: string) { - for (const transport of this.transports) { - transport.setBridgeLatestUrl(url); - } - } - - setBridgeLatestVersion(version: string) { - for (const transport of this.transports) { - transport.setBridgeLatestVersion(version); - } - } - - stop() { - for (const transport of this.transports) { - transport.stop(); - } - } -} diff --git a/packages/transport/src/index.ts b/packages/transport/src/index.ts index 523154242e0e..25eb5c01078d 100644 --- a/packages/transport/src/index.ts +++ b/packages/transport/src/index.ts @@ -1,8 +1,3 @@ -import BridgeTransportV2 from './bridge/v2'; -import LowlevelTransportWithSharedConnections from './lowlevel/withSharedConnections'; -import FallbackTransport from './fallback'; -import WebUsbPlugin from './lowlevel/webusb'; - // Long.js needed to make protobuf encoding work with numbers over Number.MAX_SAFE_INTEGER // Docs claim that it should be enough to only install this dependency and it will be required automatically // see: https://github.com/protobufjs/protobuf.js/#compatibility @@ -14,18 +9,21 @@ import * as Long from 'long'; protobuf.util.Long = Long; protobuf.configure(); -export type { - Transport, - AcquireInput, - TrezorDeviceInfoWithSession, - MessageFromTrezor, -} from './types'; +export type { AcquireInput, TrezorDeviceInfoWithSession, MessageFromTrezor } from './types'; +export type { Transport } from './transports/abstract'; export { Messages } from './types'; -export default { - BridgeV2: BridgeTransportV2, - Fallback: FallbackTransport, - Lowlevel: LowlevelTransportWithSharedConnections, - WebUsb: WebUsbPlugin, -}; +// web + node +export { BridgeTransport } from './transports/bridge'; +// web only +export { WebUsbTransport } from './transports/webusb.browser'; +// node only +export { NodeUsbTransport } from './transports/nodeusb'; + +export { getAvailableTransport } from './utils/getAvailableTransport'; + +/** + * Set fetch for this lib globally + */ +export { setFetch } from './utils/http'; diff --git a/packages/transport/src/lowlevel/protobuf/messages.ts b/packages/transport/src/lowlevel/protobuf/messages.ts index 145bfd3a4f18..d18659fb2378 100644 --- a/packages/transport/src/lowlevel/protobuf/messages.ts +++ b/packages/transport/src/lowlevel/protobuf/messages.ts @@ -2,13 +2,6 @@ import * as protobuf from 'protobufjs/light'; -export function parseConfigure(data: protobuf.INamespace) { - if (typeof data === 'string') { - return protobuf.Root.fromJSON(JSON.parse(data)); - } - return protobuf.Root.fromJSON(data); -} - export const createMessageFromName = (messages: protobuf.Root, name: string) => { const Message = messages.lookupType(name); const MessageType = messages.lookupEnum('MessageType'); diff --git a/packages/transport/src/lowlevel/protocol/decode.ts b/packages/transport/src/lowlevel/protocol/decode.ts index 21cd26f93976..c2543727fb5a 100644 --- a/packages/transport/src/lowlevel/protocol/decode.ts +++ b/packages/transport/src/lowlevel/protocol/decode.ts @@ -1,5 +1,5 @@ import * as ByteBuffer from 'bytebuffer'; -import { MESSAGE_HEADER_BYTE } from '../../config'; +import { MESSAGE_HEADER_BYTE } from '../../constants'; /** * Reads meta information from buffer diff --git a/packages/transport/src/lowlevel/protocol/encode.ts b/packages/transport/src/lowlevel/protocol/encode.ts index 658548b2a24e..e2bb58edde8e 100644 --- a/packages/transport/src/lowlevel/protocol/encode.ts +++ b/packages/transport/src/lowlevel/protocol/encode.ts @@ -1,6 +1,6 @@ import * as ByteBuffer from 'bytebuffer'; -import { HEADER_SIZE, MESSAGE_HEADER_BYTE, BUFFER_SIZE } from '../../config'; +import { HEADER_SIZE, MESSAGE_HEADER_BYTE, BUFFER_SIZE } from '../../constants'; type Options = { chunked: Chunked; diff --git a/packages/transport/src/lowlevel/sharedPlugin.ts b/packages/transport/src/lowlevel/sharedPlugin.ts deleted file mode 100644 index 1a1547812198..000000000000 --- a/packages/transport/src/lowlevel/sharedPlugin.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type TrezorDeviceInfoDebug = { - path: string; - debug: boolean; -}; - -export type LowlevelTransportSharedPlugin = { - enumerate: () => Promise>; - send: (path: string, data: ArrayBuffer, debug: boolean) => Promise; - receive: (path: string, debug: boolean) => Promise; - connect: (path: string, debug: boolean, first: boolean) => Promise; - disconnect: (path: string, debug: boolean, last: boolean) => Promise; - - // webusb has a different model, where you have to - // request device connection - requestDevice: () => Promise; - requestNeeded: boolean; - - init: (debug?: boolean) => Promise; - version: string; - name: string; - - // in signal hid API, there is an issue that we cannot simultaneously - // write and list devices. - // HOWEVER, there is a separate (and maybe connected) issue in Chrome, - // where sometimes write doesn't fail on disconnect unless we enumerate - // so we need to have an "optional lock" - allowsWriteAndEnumerate: boolean; -}; diff --git a/packages/transport/src/lowlevel/webusb.ts b/packages/transport/src/lowlevel/webusb.ts index 0074c77789d4..a05d866de688 100644 --- a/packages/transport/src/lowlevel/webusb.ts +++ b/packages/transport/src/lowlevel/webusb.ts @@ -45,6 +45,14 @@ export default class WebUsbPlugin { } } + async enumerate() { + console.log('transport: webusb: enumerate'); + return (await this._listDevices()).map(info => ({ + path: info.path, + debug: info.debug, + })); + } + _deviceHasDebugLink(device: USBDevice) { try { const iface = device.configurations[0].interfaces[DEBUG_INTERFACE_ID].alternates[0]; @@ -98,13 +106,6 @@ export default class WebUsbPlugin { _lastDevices: Array<{ path: string; device: USBDevice; debug: boolean }> = []; - async enumerate() { - return (await this._listDevices()).map(info => ({ - path: info.path, - debug: info.debug, - })); - } - _findDevice(path: string) { const deviceO = this._lastDevices.find(d => d.path === path); if (deviceO == null) { diff --git a/packages/transport/src/session.ts b/packages/transport/src/session.ts new file mode 100644 index 000000000000..82639414bc15 --- /dev/null +++ b/packages/transport/src/session.ts @@ -0,0 +1,223 @@ +// @ts-nocheck + +// todo: filename? +// multi-device +// multi-device-coordinator +// session + +// To ensure that two website don't read from/to Trezor at the same time, I need a sharedworker +// to synchronize them. +// However, sharedWorker cannot directly use webusb API... so I need to send messages +// about intent to acquire/release and then send another message when that is done. +// Other windows then can acquire/release + +import { create as createDeferred } from './utils/defered'; +import type { Deferred } from './utils/defered'; + +interface LockResult { + id: number; + good?: boolean; +} + +const sessions: { + // [path]: session + [path: string]: string; +} = {}; +let lock: Deferred | null = null; +let waitPromise: Promise = Promise.resolve(); + +// function handle(params: { type: 'enumerateIntent'; payload: EnumerateIntent }): Promise; +// function handle(params: { type: 'enumerateDone'; payload: EnumerateDone }): Promise; +// function handle(params: { type: 'acquireIntent'; payload: AcquireIntent }): Promise; +// function handle(params: { type: 'acquireDone'; payload: AcquireDone }): Promise; +// function handle(params: { type: 'releaseIntent'; payload: ReleaseIntent }): Promise; +// function handle(params: { type: 'releaseDone'; payload: ReleaseDone }): Promise; +// function handle(params: any) { +// console.log(params); +// return Promise.resolve(); +// } + +const startLock = () => { + const newLock = createDeferred(); + lock = newLock; + setTimeout(() => newLock.reject(new Error('Timed out')), 10 * 1000); +}; + +const releaseLock = (obj: LockResult) => { + if (lock == null) { + // TODO: ??? + return; + } + lock.resolve(obj); +}; + +const waitForLock = () => { + if (lock == null) { + // TODO: ??? + return Promise.reject(new Error('???')); + } + return lock.promise; +}; + +const waitInQueue = (fn: () => Promise) => { + const res = waitPromise.then(() => fn()); + waitPromise = res.catch(() => {}); +}; + +const handleEnumerateIntent = (id: number) => { + startLock(); + dispatch({ type: 'ok' }, id); + + // if lock times out, promise rejects and queue goes on + return waitForLock().then(obj => { + dispatch({ type: 'ok' }, obj.id); + }); +}; + +const handleReleaseDone = (id: number) => { + releaseLock({ id }); +}; + +const handleReleaseOnClose = (session: string) => { + let path_: string | null = null; + Object.keys(sessions).forEach(kpath => { + if (sessions[kpath] === session) { + path_ = kpath; + } + }); + if (path_ == null) { + return Promise.resolve(); + } + + const path: string = path_; + delete sessions[path]; + return Promise.resolve(); +}; + +const handleReleaseIntent = (session: string, id: number) => { + let path_: string | null = null; + const otherSessions = sessions; + Object.keys(sessions).forEach(kpath => { + if (sessions[kpath] === session) { + path_ = kpath; + } + }); + if (path_ == null) { + dispatch({ type: 'double-release' }, id); + return Promise.resolve(); + } + + const path: string = path_; + + const otherSession = otherSessions[path]; + + startLock(); + dispatch({ type: 'path', path, otherSession }, id); + + // if lock times out, promise rejects and queue goes on + return waitForLock().then(obj => { + // failure => nothing happens, but still has to reply "ok" + delete sessions[path]; + dispatch({ type: 'ok' }, obj.id); + }); +}; + +const handleGetSessions = (id: number, devices?: Array) => { + if (devices != null) { + const connected: { [path: string]: boolean } = {}; + devices.forEach(d => { + connected[d.path] = true; + }); + Object.keys(sessions).forEach(path => { + if (!sessions[path]) { + delete sessions[path]; + } + }); + } + dispatch({ type: 'sessions', sessions }, id); + return Promise.resolve(); +}; + +let lastSession = 0; +const handleAcquireDone = (id: number) => { + releaseLock({ good: true, id }); +}; + +const handleAcquireFailed = (id: number) => { + releaseLock({ good: false, id }); +}; + +const handleAcquireIntent = (path: string, id: number, previous?: string) => { + let error = false; + const thisTable = sessions; + const otherTable = sessions; + const realPrevious = thisTable[path]; + + if (realPrevious == null) { + error = previous != null; + } else { + error = previous !== realPrevious; + } + if (error) { + dispatch({ type: 'wrong-previous-session' }, id); + return Promise.resolve(); + } + startLock(); + dispatch({ type: 'other-session', otherSession: otherTable[path] }, id); + // if lock times out, promise rejects and queue goes on + return waitForLock().then(obj => { + if (obj.good) { + lastSession++; + let session = lastSession.toString(); + thisTable[path] = session; + dispatch({ type: 'session-number', number: session }, obj.id); + } else { + // failure => nothing happens, but still has to reply "ok" + dispatch({ type: 'ok' }, obj.id); + } + }); +}; + +const handleDeviceActivity = ({ + id, + activity, +}: { + id: number; + activity: MessageToSharedWorker; +}) => { + const { path, previous, debug, session, devices } = activity; + + if (activity.type === 'acquire-intent') { + waitInQueue(() => handleAcquireIntent(path, id, previous, debug)); + } + if (activity.type === 'acquire-done') { + handleAcquireDone(id); // port is the same as original + } + if (activity.type === 'acquire-failed') { + handleAcquireFailed(id); // port is the same as original + } + if (activity.type === 'get-sessions') { + waitInQueue(() => handleGetSessions(id)); + } + + if (activity.type === 'get-sessions-and-disconnect') { + waitInQueue(() => handleGetSessions(id, devices)); + } + + if (activity.type === 'release-onclose') { + waitInQueue(() => handleReleaseOnClose(session)); + } + + if (activity.type === 'release-intent') { + waitInQueue(() => handleReleaseIntent(session, debug, id)); + } + if (activity.type === 'release-done') { + handleReleaseDone(id); // port is the same as original + } + if (activity.type === 'enumerate-intent') { + waitInQueue(() => handleEnumerateIntent(id)); + } + if (activity.type === 'enumerate-done') { + handleReleaseDone(id); // port is the same as original + } +}; diff --git a/packages/transport/src/sessions/backend.ts b/packages/transport/src/sessions/backend.ts new file mode 100644 index 000000000000..ec4c48094710 --- /dev/null +++ b/packages/transport/src/sessions/backend.ts @@ -0,0 +1,188 @@ +// todo: ensure this will be only singleton + +import { + // EnumerateIntent, + // EnumerateDone, + AcquireIntent, + AcquireDone, + ReleaseIntent, + ReleaseDone, +} from './types'; + +import { create as createDeferred } from '../utils/defered'; +import type { Deferred } from '../utils/defered'; + +// meaning "ok", go on with whatever you wanted to do +type ResponseAck = { + type: 'ack'; + // might return session + session?: any; // ? +}; + +// meaning intent is impossible to satisfy +type ResponseNope = { + type: 'nope'; + reason: string; +}; + +type Response = Promise; + +/** + * Goals: + * - synchronize exclusive access to device (locks) + * - ensure device has not changed without others realizing (sessions). // todo: really? + * + * + * Concepts: + * - we have no control about the async process between lock and unlock, it happens elsewhere + * - caller has the responsibility to lock and unlock + * - we can say we trust the caller but not really + * - auto time out of any lock, so we don't want to get stuck. TODO: does it make sense, if we trust the caller? + */ + +export class SessionsBackend { + // response method responsible for notifying all clients + + private sessions: Record = {}; + + // if lock is set, somebody is doing something with device. we have to wait + private lockPromise?: Deferred; + private lockTimeout?: ReturnType; + + private last = 0; + constructor() {} + + private getId() { + return this.last++; + } + + /** + * enumerate intent + * - caller wants to enumerate usb + * - basically "wait for unlocked and lock" + */ + async enumerateIntent(): Response { + // await new Promise(resolve => setTimeout(() => resolve(null), 3000)); + const id = this.getId(); + + console.log(id, 'enumerateIntent start'); + + if (this.lockPromise) { + await this.waitForUnlocked(); + } + + console.log(id, 'enumerateIntent after unlocked'); + + this.startLock(); + console.log(id, 'enumerateIntent end'); + + // @ts-expect-error returning something for tests, temporarily + return { type: 'ack', data: id }; + } + + async enumerateDone() { + this.clearLock(); + return { type: 'ack' }; + } + + /** + * call intent - I have session + * - I am going to send something to device and I want to use this session. + * - a] it is ok, no other session was issued + * - b] it is not ok, other session was issued + */ + + /** + * acquire intent + * - I would like to claim this device for myself + * - a] there is another session + * - b] there is no another session + */ + async acquireIntent(payload: AcquireIntent) { + const id = this.getId(); + console.log(id, 'acquireIntent', payload); + const prevSession = payload.prev; + const prevSessionBackend = this.sessions[payload.path]; + + if (!prevSession) { + return { type: 'nope', reason: 'must provide prev session!' }; + } + + if (this.lockPromise) { + console.log(id, 'acquireIntent waiting'); + await this.waitForUnlocked(); + console.log(id, 'acquireIntent continue'); + } + + if (prevSession === 'null') { + this.startLock(); + this.sessions[payload.path] = `${id}`; + return { type: 'ack', session: this.sessions[payload.path] }; + } + + if (prevSession === prevSessionBackend) { + this.startLock(); + return { type: 'ack', session: this.sessions[payload.path] }; + } else { + return { + type: 'nope', + reason: `used elsewhere, ${prevSession}, ${prevSessionBackend}`, + }; + } + + // @ts-expect-error unreachable + throw new Error('should never get here'); + } + + async acquireDone(_payload: AcquireDone) { + this.clearLock(); + return { type: 'ack' }; + } + + async releaseIntent(_payload: ReleaseIntent) { + if (this.lockPromise) { + await this.waitForUnlocked(); + } + } + async releaseDone(_payload: ReleaseDone) {} + + async getSessions() { + return this.sessions; + } + + private async waitForUnlocked() { + if (this.lockPromise) { + return this.lockPromise.promise; + } + } + + // gives client 1 !?!? second to carry out intended operations with device + private startLock() { + // lock already in place + if (this.lockPromise?.promise) { + console.log('Sessions backend: start lock: already exists!'); + return; + } + console.log('start lock, creating promise'); + this.lockPromise = createDeferred(); + + // to ensure that communication with device will not get stuck forever, + // lock times out: + // - if cleared by client (enumerateIntent, enumerateDone) + // - after 1 second automatically + this.lockTimeout = setTimeout(() => { + // this.lockPromise && this.lockPromise.resolve(undefined); + }, 1000 * 10); + } + + private clearLock() { + if (this.lockPromise) { + this.lockPromise.resolve('meow'); + + this.lockPromise = undefined; + } + if (this.lockTimeout) { + clearTimeout(this.lockTimeout); + } + } +} diff --git a/packages/transport/src/sessions/client.ts b/packages/transport/src/sessions/client.ts new file mode 100644 index 000000000000..c427847d083e --- /dev/null +++ b/packages/transport/src/sessions/client.ts @@ -0,0 +1,53 @@ +import { + // EnumerateIntent, + // EnumerateDone, + AcquireIntent, + AcquireDone, + ReleaseIntent, + ReleaseDone, +} from './types'; +import { SessionsBackend } from './backend'; + +function request(params: { type: 'enumerateIntent' }): Promise; +function request(params: { type: 'enumerateDone' }): Promise; +function request(params: { type: 'acquireIntent'; payload: AcquireIntent }): Promise; +function request(params: { type: 'acquireDone'; payload: AcquireDone }): Promise; +function request(params: { type: 'releaseIntent'; payload: ReleaseIntent }): Promise; +function request(params: { type: 'releaseDone'; payload: ReleaseDone }): Promise; +function request(_params: any) { + return Promise.resolve(); +} + +/** + * This class: + * - is responsible provides unified API for communication with SessionsBackend singleton + * - actual mean of communication should be implemented in "request" param + */ +export class SessionsClient { + // request method responsible for requesting SessionBackend + request: typeof request; + constructor(requestFn: typeof request) { + this.request = requestFn; + } + // there will be reading from usb + enumerateIntent(): ReturnType { + return this.request({ type: 'enumerateIntent' }); + } + enumerateDone(): ReturnType { + return this.request({ type: 'enumerateDone' }); + } + // there will be reading/writing from usb for particular path + acquireIntent(payload: AcquireIntent): ReturnType { + return this.request({ type: 'acquireIntent', payload }); + } + acquireDone(payload: AcquireDone): ReturnType { + return this.request({ type: 'acquireDone', payload }); + } + // there will be no activity for particular path now + releaseIntent(payload: ReleaseIntent): ReturnType { + return this.request({ type: 'releaseIntent', payload }); + } + releaseDone(payload: ReleaseDone): ReturnType { + return this.request({ type: 'releaseDone', payload }); + } +} diff --git a/packages/transport/src/sessions/sharedWorker.ts b/packages/transport/src/sessions/sharedWorker.ts new file mode 100644 index 000000000000..209dbbc44b17 --- /dev/null +++ b/packages/transport/src/sessions/sharedWorker.ts @@ -0,0 +1,6 @@ +// todo: sharedworker for sync of webusb +// todo: in theory, it could be possible to sync different transport layers and different envs +// for example, we could sync browser using webusb with desktop using nodeusb using sessions backend as http server on desktop app +// if no dasktop app is running, sessions backend must live in sharedworker + +export const foo = 'meow'; diff --git a/packages/transport/src/sessions/types.ts b/packages/transport/src/sessions/types.ts new file mode 100644 index 000000000000..13d6656656b1 --- /dev/null +++ b/packages/transport/src/sessions/types.ts @@ -0,0 +1,9 @@ +export interface EnumerateIntent {} +export interface EnumerateDone {} +export interface AcquireIntent { + prev?: string; + path: string; +} +export interface AcquireDone {} +export interface ReleaseIntent {} +export interface ReleaseDone {} diff --git a/packages/transport/src/transports/abstract.ts b/packages/transport/src/transports/abstract.ts new file mode 100644 index 000000000000..065845e6f2b7 --- /dev/null +++ b/packages/transport/src/transports/abstract.ts @@ -0,0 +1,213 @@ +import * as protobuf from 'protobufjs/light'; +import { EventEmitter } from 'events'; +import { + TrezorDeviceInfoWithSession, + AcquireInput, + MessageFromTrezor, + TrezorDeviceInfoWithSession as DeviceDescriptor, +} from '../types'; + +type ConstructorParams = { + messages: Record; +}; + +type Descriptor = { path: string }; + +type DeviceDescriptorDiff = { + didUpdate: boolean; + connected: DeviceDescriptor[]; + disconnected: DeviceDescriptor[]; + changedSessions: DeviceDescriptor[]; + acquired: DeviceDescriptor[]; + released: DeviceDescriptor[]; + descriptors: DeviceDescriptor[]; +}; + +const getDiff = ( + current: DeviceDescriptor[], + descriptors: DeviceDescriptor[], +): DeviceDescriptorDiff => { + const connected = descriptors.filter(d => current.find(x => x.path === d.path) === undefined); + const disconnected = current.filter( + d => descriptors.find(x => x.path === d.path) === undefined, + ); + const changedSessions = descriptors.filter(d => { + const currentDescriptor = current.find(x => x.path === d.path); + if (currentDescriptor) { + return currentDescriptor.session !== d.session; + } + return false; + }); + const acquired = changedSessions.filter(d => typeof d.session === 'string'); + const released = changedSessions.filter(d => typeof d.session !== 'string'); + + const didUpdate = connected.length + disconnected.length + changedSessions.length > 0; + + return { + connected, + disconnected, + changedSessions, + acquired, + released, + didUpdate, + descriptors, + }; +}; + +// todo: duplicated with connect +const TRANSPORT = { + START: 'transport-start', + ERROR: 'transport-error', + UPDATE: 'transport-update', + STREAM: 'transport-stream', + REQUEST: 'transport-request_device', + DISABLE_WEBUSB: 'transport-disable_webusb', + START_PENDING: 'transport-start_pending', +} as const; + +// todo: duplicated with connect +const DEVICE = { + // device list events + CONNECT: 'device-connect', + CONNECT_UNACQUIRED: 'device-connect_unacquired', + DISCONNECT: 'device-disconnect', + CHANGED: 'device-changed', + ACQUIRE: 'device-acquire', + RELEASE: 'device-release', + ACQUIRED: 'device-acquired', + RELEASED: 'device-released', + USED_ELSEWHERE: 'device-used_elsewhere', + + LOADING: 'device-loading', + + // trezor-link events in protobuf format + BUTTON: 'button', + PIN: 'pin', + PASSPHRASE: 'passphrase', + PASSPHRASE_ON_DEVICE: 'passphrase_on_device', + WORD: 'word', +} as const; + +export abstract class Transport extends EventEmitter { + configured = false; + messages: protobuf.Root; + name = ''; + version = ''; + + isOutdated = false; + + descriptors: Descriptor[]; + + constructor({ messages }: ConstructorParams) { + super(); + this.descriptors = []; + this.messages = protobuf.Root.fromJSON(messages as protobuf.INamespace); + } + + /** + * Tries to initiate transport. Transport might not be available e.g. bridge not running. + * TODO: return type? should it ever throw? + */ + abstract init(): Promise<{ success: true } | { success: false; message?: string }>; + + /** + * Setup listeners for device changes (connect, disconnect, change?). + * What should it do? Will start emitting DEVICE events after this is fired? + * - should call onDescriptorsUpdated in the end + */ + abstract listen(): Promise; + + /** + * List Trezor devices + */ + abstract enumerate(): Promise; + + /** + * Acquire session + */ + abstract acquire({ input, first }: { input: AcquireInput; first?: boolean }): Promise; + + /** + * Release session + */ + abstract release(session: string, onclose: boolean): Promise; + + /** + * Encode data and write it to transport layer + */ + abstract send({ + path, + session, + data, + name, + }: { + path?: string; + session?: string; + // wrap object and name? + name: string; + data: Record; + }): Promise; + + /** + * Only read from transport + */ + abstract receive({ + path, + session, + }: { + path?: string; + session?: string; + }): Promise; + + /** + * send and read after that + */ + abstract call({ + session, + name, + data, + }: { + session: string; + name: string; + data: Record; + }): Promise; + + // todo: not sure if needed. probably not + stop() { + console.log('abstract: stop transport'); + } + + /** + * common method for all types of transports. should be called + * after every enumeration cycle + */ + _onListenResult(nextDescriptors: Descriptor[]) { + // console.log('transport: abstract: _onListenResult'); + // console.log('transport: abstract: _onListenResult: this.descriptors', this.descriptors); + // console.log('transport: abstract: _onListenResult: nextDescriptors', nextDescriptors); + + const diff = getDiff(this.descriptors, nextDescriptors); + // console.log('transport: abstract: diff', diff); + + this.descriptors = nextDescriptors; + + if (diff.didUpdate) { + diff.connected.forEach(d => { + this.emit(DEVICE.CONNECT, d); + }); + diff.disconnected.forEach(d => { + this.emit(DEVICE.DISCONNECT, d); + }); + diff.acquired.forEach(d => { + this.emit(DEVICE.ACQUIRED, d); + }); + diff.released.forEach(d => { + this.emit(DEVICE.RELEASED, d); + }); + diff.changedSessions.forEach(d => { + this.emit(DEVICE.CHANGED, d); + }); + this.emit(TRANSPORT.UPDATE, diff); + } + } +} diff --git a/packages/transport/src/transports/bridge.ts b/packages/transport/src/transports/bridge.ts new file mode 100644 index 000000000000..96a3fb8ca606 --- /dev/null +++ b/packages/transport/src/transports/bridge.ts @@ -0,0 +1,198 @@ +import { request as http } from '../utils/http'; +import * as check from '../utils/highlevel-checks'; +import { buildOne } from '../lowlevel/send'; +import { receiveOne } from '../lowlevel/receive'; +import { DEFAULT_URL } from '../constants'; +import { Transport } from './abstract'; + +import type { AcquireInput, TrezorDeviceInfoWithSession } from '../types'; + +const resolveAfter = (msec: number, value?: any) => + new Promise(resolve => { + setTimeout(resolve, msec, value); + }); + +type IncompleteRequestOptions = { + body?: Array | Record | string; + url: string; +}; + +export class BridgeTransport extends Transport { + bridgeVersion?: string; + name = 'BridgeTransport'; + url: string; + priority = 1; + + constructor({ + url = DEFAULT_URL, + messages, + }: ConstructorParameters[0] & { url?: string }) { + super({ messages }); + this.url = url; + } + + async init() { + try { + const infoS = await http({ + url: this.url, + method: 'POST', + }); + const info = check.info(infoS); + this.version = info.version; + return { + success: true, + }; + } catch (err) { + return { + success: false, + message: err.message, + }; + } + + // const newVersion = + // typeof this.bridgeVersion === 'string' + // ? this.bridgeVersion + // : check.version( + // await http({ + // url: `${this.newestVersionUrl}?${Date.now()}`, + // method: 'GET', + // }), + // ); + // this.isOutdated = versionUtils.isNewer(newVersion, this.version); + } + + // todo: + // - listen should not throw on expected timeout, this case should be handled + // inside listen method and next call to listen should be made. now, this is done + // Device descriptor, but this is wrong, this throw should be expected and as that + // it should be handled directly here. + // @ts-ignore + async listen(): Promise { + const listenTimestamp = new Date().getTime(); + + const body = this.descriptors.map(d => { + return { + ...d, + // @ts-ignore + // session: d.session === 'null' ? null : d.session, + }; + }); + + try { + const devicesS = await this._post({ + url: '/listen', + body, + }); + + const devices = check.devices(devicesS); + this._onListenResult(devices); + return this.listen(); + } catch (err) { + console.log('transport: bridge: listen: err', err); + + // todo: + // distinguish errors maybe? + + const time = new Date().getTime() - listenTimestamp; + + // todo: I still fully don't understand why we need this timeout + if (time > 1100) { + await resolveAfter(1000, null); + return this.listen(); + } else { + this.emit('transport-error', err); + } + } + } + + async enumerate() { + const devicesS = await this._post({ url: '/enumerate' }); + const devices = check.devices(devicesS); + return devices; + } + + async acquire({ input }: { input: AcquireInput }) { + console.log('transport: bridge: acquire: input', input); + const previousStr = input.previous == null ? 'null' : input.previous; + const url = `/acquire/${input.path}/${previousStr}`; + const acquireS = await this._post({ url }); + console.log('transport: bridge: acquire acquireS', acquireS); + + return check.acquire(acquireS); + } + + async release(session: string, onclose: boolean) { + const res = this._post({ + url: `/release/${session}`, + }); + if (onclose) { + return; + } + await res; + } + + async call({ + session, + name, + data, + }: { + session: string; + name: string; + data: Record; + }) { + const { messages } = this; + const o = buildOne(messages, name, data); + const outData = o.toString('hex'); + const resData = await this._post({ + url: `/call/${session}`, + body: outData, + }); + if (typeof resData !== 'string') { + throw new Error('Returning data is not string.'); + } + const jsonData = receiveOne(messages, resData); + return check.call(jsonData); + } + + async send({ + session, + name, + data, + }: { + session: string; + data: Record; + name: string; + }) { + const { messages } = this; + const outData = buildOne(messages, name, data).toString('hex'); + await this._post({ + url: `/post/${session}`, + body: outData, + }); + } + + async receive({ session }: { session: string }) { + const { messages } = this; + const resData = await this._post({ + url: `/read/${session}`, + }); + if (typeof resData !== 'string') { + throw new Error('Returning data is not string.'); + } + const jsonData = receiveOne(messages, resData); + return check.call(jsonData); + } + + /** + * All bridge endpoints use POST methods + * For documentation, look here: https://github.com/trezor/trezord-go#api-documentation + */ + _post(options: IncompleteRequestOptions) { + return http({ + ...options, + method: 'POST', + url: this.url + options.url, + skipContentTypeHeader: true, + }); + } +} diff --git a/packages/transport/src/transports/nodeusb.browser.ts b/packages/transport/src/transports/nodeusb.browser.ts new file mode 100644 index 000000000000..ea576051fb75 --- /dev/null +++ b/packages/transport/src/transports/nodeusb.browser.ts @@ -0,0 +1,12 @@ +export class NodeUsbTransport { + constructor() { + // throw new Error('NodeUsbTransport can not be used in browser environment'); + } + + init() { + return { + success: false, + message: 'NodeUsbTransport can not be used in browser environment', + }; + } +} diff --git a/packages/transport/src/transports/nodeusb.ts b/packages/transport/src/transports/nodeusb.ts new file mode 100644 index 000000000000..869d980ef6d8 --- /dev/null +++ b/packages/transport/src/transports/nodeusb.ts @@ -0,0 +1,26 @@ +import { WebUSB } from 'usb'; + +import { Transport } from './abstract'; +import { UsbTransport } from './usb'; +import { SessionsClient } from '../sessions/client'; +import { SessionsBackend } from '../sessions/backend'; + +const backend = new SessionsBackend(); + +// notes: +// to make it work I needed to run `sudo chmod -R 777 /dev/bus/usb/` + +export class NodeUsbTransport extends UsbTransport { + name = 'NodeUsbTransport'; + + constructor({ messages }: ConstructorParameters[0]) { + super({ + messages, + usbInterface: new WebUSB({ + allowAllDevices: true, // return all devices, not only authorized + }), + // @ts-expect-error todo: improve ts, two unions.. + sessionsClient: new SessionsClient(params => backend[params.type](params.payload)), + }); + } +} diff --git a/packages/transport/src/transports/usb.ts b/packages/transport/src/transports/usb.ts new file mode 100644 index 000000000000..b13fdf76dd20 --- /dev/null +++ b/packages/transport/src/transports/usb.ts @@ -0,0 +1,294 @@ +/// + +import { Transport } from './abstract'; +import { buildBuffers } from '../lowlevel/send'; +import { receiveAndParse } from '../lowlevel/receive'; +import { SessionsClient } from '../sessions/client'; +import { AcquireInput, MessageFromTrezor } from '../types'; +import { + CONFIGURATION_ID, + ENDPOINT_ID, + INTERFACE_ID, + T1HID_VENDOR, + TREZOR_DESCS, +} from '../constants'; + +type UsbTransportConstructorParams = ConstructorParameters[0] & { + usbInterface: any; // this.usbInterface | nodeusb + sessionsClient: typeof SessionsClient['prototype']; +}; + +export class UsbTransport extends Transport { + usbInterface: UsbTransportConstructorParams['usbInterface']; + sessionsClient: UsbTransportConstructorParams['sessionsClient']; + + constructor({ messages, usbInterface, sessionsClient }: UsbTransportConstructorParams) { + super({ messages }); + this.usbInterface = usbInterface; + this.sessionsClient = sessionsClient; + } + + init() { + console.log('transport. usb. init'); + + if (!this.usbInterface) { + return Promise.resolve({ + success: false, + message: 'No usb interface available', + }); + } + + return Promise.resolve({ + success: true, + }); + } + + // todo: should be called only once. maybe add some runtime guarantees for it. + // this is different from bridge, there, listen triggers itself again and again + async listen() { + const devices = await this._listDevices(); + this._onListenResult(devices); + + console.log('transport: usb: listen'); + const onConnect = async (_event: USBConnectionEvent) => { + const devices = await this._listDevices(); + this._onListenResult(devices); + }; + + this.usbInterface.onconnect = onConnect; + this.usbInterface.ondisconnect = onConnect; + + return Promise.resolve([]); + } + + async enumerate() { + const res = await this.sessionsClient.enumerateIntent(); + console.log('transport: usb: enumerateIntent res', res); + console.log('transport: usb: enumerate'); + const devices = await this._listDevices(); + console.log('transport: usb: enumerate devices=', devices); + + this._onListenResult(devices); + + await this.sessionsClient.enumerateDone(); + + return devices.map(info => ({ + path: info.path, + })); + } + + _deviceIsHid(device: USBDevice) { + return device.vendorId === T1HID_VENDOR; + } + + _filterDevices(devices: any[]) { + const trezorDevices = devices.filter(dev => { + const isTrezor = TREZOR_DESCS.some( + desc => dev.vendorId === desc.vendorId && dev.productId === desc.productId, + ); + return isTrezor; + }); + const hidDevices = trezorDevices.filter(dev => this._deviceIsHid(dev)); + const nonHidDevices = trezorDevices.filter(dev => !this._deviceIsHid(dev)); + return [hidDevices, nonHidDevices]; + } + + _createDevices(nonHidDevices: any[]) { + let bootloaderId = 0; + + return nonHidDevices.map(device => { + // path is just serial number + // more bootloaders => number them, hope for the best + const { serialNumber } = device; + let path = serialNumber == null || serialNumber === '' ? 'bootloader' : serialNumber; + if (path === 'bootloader') { + bootloaderId++; + path += bootloaderId; + } + return { path, device }; + }); + } + + async _listDevices() { + const devices = await this.usbInterface.getDevices(); + // this._lastDevices = nonHidDevices.map(device => { + // // path is just serial number + // // more bootloaders => number them, hope for the best + // const { serialNumber } = device; + // let path = serialNumber == null || serialNumber === '' ? 'bootloader' : serialNumber; + // if (path === 'bootloader') { + // bootloaderId++; + // path += bootloaderId; + // } + // const debug = this._deviceHasDebugLink(device); + // return { path, device, debug }; + // }); + + const [_hidDevices, nonHidDevices] = this._filterDevices(devices); + this._lastDevices = this._createDevices(nonHidDevices); + + // const oldUnreadableHidDevice = this.unreadableHidDevice; + // this.unreadableHidDevice = hidDevices.length > 0; + + return this._lastDevices; + } + + _lastDevices: { path: string; device: USBDevice }[] = []; + + private _findDevice(path: string) { + const deviceO = this._lastDevices.find(d => d.path === path); + if (deviceO == null) { + throw new Error('Action was interrupted.'); + } + return deviceO.device; + } + + async call({ + session, + name, + path = this._lastDevices[0]?.path || '', + data, + }: { + session: string; + path: string; + name: string; + data: Record; + }): Promise { + await this.send({ name, path, data, session }); + return this.receive({ path }); + } + + async send({ + path, + data, + // session, + name, + }: { + path: string; + data: Record; + session: string; + name: string; + }) { + const device: USBDevice = this._findDevice(path); + + const buffers = buildBuffers(this.messages!, name, data); + for (const buffer of buffers) { + const newArray: Uint8Array = new Uint8Array(64); + newArray[0] = 63; + newArray.set(new Uint8Array(buffer), 1); + + if (!device.opened) { + await this.acquire({ input: { path } }); + } + + const endpoint = ENDPOINT_ID; + device.transferOut(endpoint, newArray); + } + } + + async receive({ path }: { path: string }) { + console.log('transport: usb receive, path', path); + const message: MessageFromTrezor = await receiveAndParse(this.messages!, () => + this._read(path), + ); + console.log('transport receive, message', message.type); + + return message; + } + + async _read(path: string): Promise { + const device = this._findDevice(path); + const endpoint = ENDPOINT_ID; + + try { + if (!device.opened) { + await this.acquire({ input: { path } }); + } + + const res = await device.transferIn(endpoint, 64); + + if (!res.data) { + throw new Error('no data'); + } + if (res.data.byteLength === 0) { + return this._read(path); + } + return res.data.buffer.slice(1); + } catch (e) { + if (e.message === 'Device unavailable.') { + throw new Error('Action was interrupted.'); + } else { + throw e; + } + } + } + + // + async acquire({ input, first = false }: { input: AcquireInput; first?: boolean }) { + console.log('transport: usb: acquire', input); + + const acquireIntentResponse = await this.sessionsClient.acquireIntent(input); + console.log('transport: usb: acquireIntentResponse: ', acquireIntentResponse); + + if (acquireIntentResponse.type === 'nope') { + // return or throw? + } + + const { path } = input; + for (let i = 0; i < 5; i++) { + if (i > 0) { + await new Promise(resolve => setTimeout(() => resolve(undefined), i * 200)); + } + try { + await this._connectIn(path, first); + return Promise.resolve('null'); + } catch (e) { + console.log('transport:usb:acquire: catch', e); + // ignore + if (i === 4) { + throw e; + } + } + } + console.log('transport: usb: should not get here'); + + return Promise.resolve('??'); + } + + async _connectIn(path: string, first: boolean) { + const device: USBDevice = this._findDevice(path); + await device.open(); + + if (first) { + await device.selectConfiguration(CONFIGURATION_ID); + try { + // reset fails on ChromeOS and windows + await device.reset(); + } catch (error) { + // do nothing + } + } + + const interfaceId = INTERFACE_ID; + await device.claimInterface(interfaceId); + console.log('transport:usb:_connectIn done'); + } + + // todo: params different meaning from bridge + async release(path: string, last: boolean) { + const device: USBDevice = await this._findDevice(path); + + const interfaceId = INTERFACE_ID; + await device.releaseInterface(interfaceId); + if (last) { + await device.close(); + } + } + + // // TODO(karliatto): apparetly we can remove it from here since it is used in: + // // packages/suite/src/components/suite/WebusbButton/index.tsx + // async requestDevice() { + // // I am throwing away the resulting device, since it appears in enumeration anyway + // await this.usb!.requestDevice({ filters: TREZOR_DESCS }); + // } +} diff --git a/packages/transport/src/transports/webusb.browser.ts b/packages/transport/src/transports/webusb.browser.ts new file mode 100644 index 000000000000..f765fdd77ec8 --- /dev/null +++ b/packages/transport/src/transports/webusb.browser.ts @@ -0,0 +1,20 @@ +import { Transport } from './abstract'; +import { UsbTransport } from './usb'; +import { SessionsClient } from '../sessions/client'; +import { SessionsBackend } from '../sessions/backend'; + +// todo: shared worker for backend +const backend = new SessionsBackend(); + +export class WebUsbTransport extends UsbTransport { + name = 'WebusbTransport'; + + constructor({ messages }: ConstructorParameters[0]) { + super({ + messages, + usbInterface: navigator.usb, + // @ts-expect-error todo: improve ts, two unions.. + sessionsClient: new SessionsClient(params => backend[params.type](params.payload)), + }); + } +} diff --git a/packages/transport/src/transports/webusb.ts b/packages/transport/src/transports/webusb.ts new file mode 100644 index 000000000000..5e318fff2667 --- /dev/null +++ b/packages/transport/src/transports/webusb.ts @@ -0,0 +1,10 @@ +export class WebUsbTransport { + constructor() {} + + init() { + return Promise.resolve({ + success: false, + message: 'WebUsbTransport can not be used in node environment', + }); + } +} diff --git a/packages/transport/src/lowlevel/withSharedConnections.ts b/packages/transport/src/transports/withSharedConnections.ts similarity index 70% rename from packages/transport/src/lowlevel/withSharedConnections.ts rename to packages/transport/src/transports/withSharedConnections.ts index 3c858d2f0be6..10b9bc483712 100644 --- a/packages/transport/src/lowlevel/withSharedConnections.ts +++ b/packages/transport/src/transports/withSharedConnections.ts @@ -1,12 +1,14 @@ +// @ts-nocheck import { create as createDeferred, resolveTimeoutPromise } from '../utils/defered'; -import { parseConfigure } from './protobuf/messages'; -import { buildAndSend } from './send'; -import { receiveAndParse } from './receive'; +import { postModuleMessage } from '../workers/sharedConnectionWorker'; +import { Transport } from './abstract'; import type { Deferred } from '../utils/defered'; -import type { LowlevelTransportSharedPlugin, TrezorDeviceInfoDebug } from './sharedPlugin'; -import type { MessageFromTrezor, TrezorDeviceInfoWithSession, AcquireInput } from '../types'; - -import { postModuleMessage } from './sharedConnectionWorker'; +import type { + MessageFromTrezor, + TrezorDeviceInfoWithSession, + AcquireInput, + TrezorDeviceInfoDebug, +} from '../types'; // eslint-disable-next-line @typescript-eslint/no-var-requires const stringify = require('json-stable-stringify'); @@ -106,46 +108,47 @@ export type MessageFromSharedWorker = otherSession?: string; }; -export default class LowlevelTransportWithSharedConnections { - _messages: undefined | any; +export class TransportWithSharedConnections extends Transport { _sharedWorkerFactory: undefined | (() => SharedWorker); // path => promise rejecting on release - configured = false; - debug = false; deferedDebugOnRelease: { [session: string]: Deferred } = {}; deferedNormalOnRelease: { [session: string]: Deferred } = {}; defereds: { [id: number]: Deferred } = {}; - isOutdated = false; + latestId = 0; - name = 'LowlevelTransportWithSharedConnections'; - plugin: LowlevelTransportSharedPlugin; + name = 'TransportWithSharedConnections'; + + _transport: Transport; + requestNeeded = false; sharedWorker: null | SharedWorker = null; - stopped = false; - version: string; constructor( - plugin: LowlevelTransportSharedPlugin, - sharedWorkerFactory: undefined | (() => SharedWorker), + // plugin: LowlevelTransportSharedPlugin, + // sharedWorkerFactory: undefined | (() => SharedWorker), + { debug = false, transport }: { debug?: boolean; transport: Transport }, ) { - this.plugin = plugin; - this.version = plugin.version; - this._sharedWorkerFactory = sharedWorkerFactory; - if (!this.plugin.allowsWriteAndEnumerate) { - // This should never happen anyway - throw new Error('Plugin with shared connections cannot disallow write and enumerate'); - } + super({ debug }); + + this._transport = transport; + this.version = transport.version; + + // this._sharedWorkerFactory = sharedWorkerFactory; } enumerate() { return this._silentEnumerate(); } + listen(): {}; + + // @ts-expect-error async _silentEnumerate() { await this.sendToWorker({ type: 'enumerate-intent' }); + let devices: Array = []; try { - devices = await this.plugin.enumerate(); + devices = await this._transport.enumerate(); } finally { await this.sendToWorker({ type: 'enumerate-done' }); } @@ -196,11 +199,11 @@ export default class LowlevelTransportWithSharedConnections { _lastStringified = ''; - listen(old: Array) { - const oldStringified = stableStringify(old); - const last = old == null ? this._lastStringified : oldStringified; - return this._runIter(0, last); - } + // listen(old: Array) { + // const oldStringified = stableStringify(old); + // const last = old == null ? this._lastStringified : oldStringified; + // return this._runIter(0, last); + // } async _runIter( iteration: number, @@ -216,13 +219,14 @@ export default class LowlevelTransportWithSharedConnections { return this._runIter(iteration + 1, stringified); } - async acquire(input: AcquireInput, debugLink: boolean) { + async acquire({ input, debug }: { input: AcquireInput; debug: boolean }) { const messBack = await this.sendToWorker({ type: 'acquire-intent', path: input.path, previous: input.previous, - debug: debugLink, + debug, }); + if (messBack.type === 'wrong-previous-session') { throw new Error('wrong previous session'); } @@ -234,7 +238,7 @@ export default class LowlevelTransportWithSharedConnections { const reset = messBack.otherSession == null; try { - await this.plugin.connect(input.path, debugLink, reset); + await this._transport.acquire({ input: { path: input.path }, debug, first: reset }); } catch (e) { await this.sendToWorker({ type: 'acquire-failed' }); throw e; @@ -246,7 +250,7 @@ export default class LowlevelTransportWithSharedConnections { } const session = messBack2.number; - if (debugLink) { + if (debug) { this.deferedDebugOnRelease[session] = createDeferred(); } else { this.deferedNormalOnRelease[session] = createDeferred(); @@ -279,7 +283,7 @@ export default class LowlevelTransportWithSharedConnections { this._releaseCleanup(session, debugLink); try { - await this.plugin.disconnect(path, debugLink, last); + await this._transport.release(path, debugLink, last); } catch (e) { // ignore release errors, it's not important that much } @@ -294,27 +298,6 @@ export default class LowlevelTransportWithSharedConnections { } } - configure(signedData: any) { - const messages = parseConfigure(signedData); - this._messages = messages; - this.configured = true; - } - - _sendLowlevel(path: string, debug: boolean): (data: ArrayBuffer) => Promise { - return data => this.plugin.send(path, data, debug); - } - - _receiveLowlevel(path: string, debug: boolean): () => Promise { - return () => this.plugin.receive(path, debug); - } - - messages() { - if (this._messages == null) { - throw new Error('Transport not configured.'); - } - return this._messages; - } - async doWithSession( session: string, debugLink: boolean, @@ -347,47 +330,49 @@ export default class LowlevelTransportWithSharedConnections { return Promise.race([defered.rejectingPromise, resPromise]); } - call( - session: string, - name: string, - data: Record, - debugLink: boolean, - ): Promise { - const callInside = async (path: string) => { - const messages = this.messages(); - await buildAndSend(messages, this._sendLowlevel(path, debugLink), name, data); - const message = await receiveAndParse(messages, this._receiveLowlevel(path, debugLink)); - return message; - }; - - return this.doWithSession(session, debugLink, callInside); + call({ + name, + data, + debug, + session, + }: { + session: string; + name: string; + data: Record; + debug: boolean; + }): Promise { + return this.doWithSession(session, debug, () => + this._transport.call({ session, name, data, debug }), + ); } - post(session: string, name: string, data: Record, debugLink: boolean) { - const callInside = async (path: string) => { - const messages = this.messages(); - await buildAndSend(messages, this._sendLowlevel(path, debugLink), name, data); - }; - - return this.doWithSession(session, debugLink, callInside); + send({ + session, + name, + data, + debug, + }: { + session: string; + name: string; + data: Record; + debug: boolean; + }) { + return this.doWithSession(session, debug, () => + this._transport.send({ name, session, data, debug }), + ); } - read(session: string, debugLink: boolean): Promise { - const callInside = async (path: string) => { - const messages = this.messages(); - const message = await receiveAndParse(messages, this._receiveLowlevel(path, debugLink)); - return message; - }; - - return this.doWithSession(session, debugLink, callInside); + receive({ session, debug }: { session: string; debug: boolean }) { + return this.doWithSession(session, debug, () => + this._transport.receive({ session, debug }), + ); } async init(debug?: boolean) { - this.debug = !!debug; - this.requestNeeded = this.plugin.requestNeeded; - await this.plugin.init(debug); + // this.requestNeeded = this._transport.requestNeeded; + await this._transport.init(debug); // create the worker ONLY when the plugin is successfully inited - if (this._sharedWorkerFactory != null) { + if (this._sharedWorkerFactory) { this.sharedWorker = this._sharedWorkerFactory(); if (this.sharedWorker != null) { this.sharedWorker.port.onmessage = e => { @@ -398,15 +383,10 @@ export default class LowlevelTransportWithSharedConnections { } requestDevice() { - return this.plugin.requestDevice(); + return this._transport.requestDevice(); } sendToWorker(message: MessageToSharedWorker) { - if (this.stopped) { - // eslint-disable-next-line prefer-promise-reject-errors - return Promise.reject('Transport stopped.'); - } - this.latestId++; const id = this.latestId; this.defereds[id] = createDeferred(); @@ -425,9 +405,4 @@ export default class LowlevelTransportWithSharedConnections { this.defereds[m.id].resolve(m.message); delete this.defereds[m.id]; } - - stop() { - this.stopped = true; - this.sharedWorker = null; - } } diff --git a/packages/transport/src/types/index.ts b/packages/transport/src/types/index.ts index 7bfc4b6fd0f2..c3127000a385 100644 --- a/packages/transport/src/types/index.ts +++ b/packages/transport/src/types/index.ts @@ -1,55 +1,23 @@ export * as Messages from './messages'; -// does not have session +type Session = null | string; // 'null' | '1', | '2' ... + export type TrezorDeviceInfo = { path: string; }; +export type TrezorDeviceInfoDebug = { + path: string; +}; + export type TrezorDeviceInfoWithSession = TrezorDeviceInfo & { - session?: string | null; - debugSession?: string | null; - debug: boolean; + session?: Session; }; export type AcquireInput = { path: string; - previous?: string; + // todo: shouldn't previous be required? + previous?: Session; }; export type MessageFromTrezor = { type: string; message: Record }; - -export type Transport = { - enumerate(): Promise>; - listen(old?: Array): Promise>; - acquire(input: AcquireInput, debugLink: boolean): Promise; - release(session: string, onclose: boolean, debugLink: boolean): Promise; - configure(signedData: JSON | string): Promise; - call( - session: string, - name: string, - data: Record, - debugLink: boolean, - ): Promise; - post( - session: string, - name: string, - data: Record, - debugLink: boolean, - ): Promise; - read(session: string, debugLink: boolean): Promise; - // resolves when the transport can be used; rejects when it cannot - init(debug?: boolean): Promise; - stop(): void; - configured: boolean; - version: string; - name: string; - requestNeeded: boolean; - isOutdated: boolean; - setBridgeLatestUrl(url: string): void; - setBridgeLatestVersion(version: string): void; - activeName?: string; - - // webusb has a different model, where you have to - // request device connection - requestDevice: () => Promise; -}; diff --git a/packages/transport/src/utils/getAvailableTransport.ts b/packages/transport/src/utils/getAvailableTransport.ts new file mode 100644 index 000000000000..6066e115bf47 --- /dev/null +++ b/packages/transport/src/utils/getAvailableTransport.ts @@ -0,0 +1,16 @@ +import { Transport } from '../transports/abstract'; + +// First transport that inits successfully is the final one; others won't even start initiating. +export const getAvailableTransport = async (transports: Transport[]): Promise => { + let lastError: any = null; + + for (const transport of transports) { + try { + await transport.init(); + return transport; + } catch (error) { + lastError = error; + } + } + throw lastError || new Error('No transport could be initialized.'); +}; diff --git a/packages/transport/src/utils/highlevel-checks.ts b/packages/transport/src/utils/highlevel-checks.ts index 91cfd57396d7..1adbc1e145fe 100644 --- a/packages/transport/src/utils/highlevel-checks.ts +++ b/packages/transport/src/utils/highlevel-checks.ts @@ -6,7 +6,7 @@ const ERROR = 'Wrong result type.'; export function info(res: any) { if (typeof res !== 'object' || res == null) { - throw new Error('Wrong result type.'); + throw new Error(ERROR); } const { version } = res; if (typeof version !== 'string') { @@ -52,11 +52,11 @@ export function devices(res: any): Array { return { path: pathS, session: convertSession(o.session), - debugSession: convertSession(o.debugSession), - // @ts-expect-error + // @ts-expect-error - this is part of response too, might add it to type later product: o.product, vendor: o.vendor, - debug: !!o.debug, + debug: o.debug, + debugSession: o.debugSession, }; }); } diff --git a/packages/transport/src/bridge/http.ts b/packages/transport/src/utils/http.ts similarity index 100% rename from packages/transport/src/bridge/http.ts rename to packages/transport/src/utils/http.ts diff --git a/packages/transport/src/lowlevel/sharedConnectionWorker.ts b/packages/transport/src/workers/sharedConnectionWorker.ts similarity index 97% rename from packages/transport/src/lowlevel/sharedConnectionWorker.ts rename to packages/transport/src/workers/sharedConnectionWorker.ts index b6649c46c8fb..4d924979f509 100644 --- a/packages/transport/src/lowlevel/sharedConnectionWorker.ts +++ b/packages/transport/src/workers/sharedConnectionWorker.ts @@ -5,9 +5,13 @@ // Other windows then can acquire/release import { create as createDeferred } from '../utils/defered'; + import type { Deferred } from '../utils/defered'; -import type { TrezorDeviceInfoDebug } from './sharedPlugin'; -import type { MessageFromSharedWorker, MessageToSharedWorker } from './withSharedConnections'; +import type { + MessageFromSharedWorker, + MessageToSharedWorker, +} from '../transports/withSharedConnections'; +import type { TrezorDeviceInfoDebug } from '../types'; interface LockResult { id: number; diff --git a/packages/transport/tests/build-receive.test.ts b/packages/transport/tests/build-receive.test.ts index b7393b4dda93..8540bafcab77 100644 --- a/packages/transport/tests/build-receive.test.ts +++ b/packages/transport/tests/build-receive.test.ts @@ -1,10 +1,7 @@ import * as protobuf from 'protobufjs/light'; -import { buildOne } from '../src/lowlevel/send'; -import { receiveOne } from '../src/lowlevel/receive'; - -import { buildBuffers } from '../src/lowlevel/send'; -import { receiveAndParse } from '../src/lowlevel/receive'; +import { buildOne, buildBuffers } from '../src/lowlevel/send'; +import { receiveOne, receiveAndParse } from '../src/lowlevel/receive'; const messages = { StellarPaymentOp: { diff --git a/packages/transport/tests/encode-decode.test.ts b/packages/transport/tests/encode-decode.test.ts index 0f689f273237..0db2849d2a87 100644 --- a/packages/transport/tests/encode-decode.test.ts +++ b/packages/transport/tests/encode-decode.test.ts @@ -521,7 +521,7 @@ describe('Real messages', () => { fixtures.forEach(f => { describe(f.name, () => { const Messages = ProtoBuf.Root.fromJSON({ - // @ts-ignore + // @ts-expect-error nested: { messages: { nested: { ...f.message } } }, }); const Message = Messages.lookupType(`messages.${f.name}`); diff --git a/packages/transport/tests/messages.test.ts b/packages/transport/tests/messages.test.ts index c5ad7ddd51d1..0df9f1395892 100644 --- a/packages/transport/tests/messages.test.ts +++ b/packages/transport/tests/messages.test.ts @@ -1,6 +1,6 @@ import * as protobuf from 'protobufjs/light'; -const { createMessageFromName } = require('../src/lowlevel/protobuf/messages'); +import { createMessageFromName } from '../src/lowlevel/protobuf/messages'; const json = { nested: { diff --git a/packages/transport/tests/sessions.test.ts b/packages/transport/tests/sessions.test.ts new file mode 100644 index 000000000000..80b3af1cba85 --- /dev/null +++ b/packages/transport/tests/sessions.test.ts @@ -0,0 +1,71 @@ +import { SessionsClient } from '../src/sessions/client'; +import { SessionsBackend } from '../src/sessions/backend'; + +describe('sessions', () => { + let requestFn: SessionsClient['request']; + + beforeEach(() => { + const backend = new SessionsBackend(); + requestFn = params => { + return backend[params.type](params.payload); + }; + }); + + test('concurrent enumerate', async () => { + const client1 = new SessionsClient(requestFn); + const client2 = new SessionsClient(requestFn); + + // 2 clients enumerate at the same time + const client1Promise = client1.enumerateIntent({}); + const client2Promise = client2.enumerateIntent({}); + + expect(client1Promise).resolves.toMatchObject({ type: 'ack', data: 0 }); + expect(client1.enumerateDone({})).resolves.toMatchObject({ type: 'ack' }); + + expect(client2Promise).resolves.toMatchObject({ type: 'ack', data: 1 }); + expect(client2.enumerateDone({})).resolves.toMatchObject({ type: 'ack' }); + }); + + test('acquire', async () => { + const client1 = new SessionsClient(requestFn); + expect(client1.acquireIntent({ path: '1', prev: 'null' })).resolves.toMatchObject({ + type: 'ack', + session: '0', + }); + expect(client1.acquireDone({})).resolves.toMatchObject({ + type: 'ack', + }); + }); + + test('acquire', async () => { + expect.assertions(3); + + const client1 = new SessionsClient(requestFn); + const client2 = new SessionsClient(requestFn); + + const acquire1 = await client1.acquireIntent({ path: '1', prev: 'null' }); + + expect(acquire1).toMatchObject({ + type: 'ack', + session: '0', + }); + + await client1.acquireDone({}); + + const acquire11 = await client1.acquireIntent({ path: '1', prev: 'null' }); + + expect(acquire11).toMatchObject({ + type: 'ack', + session: '1', + }); + + await client1.acquireDone({}); + + const acquire12 = await client1.acquireIntent({ path: '1', prev: '1' }); + + expect(acquire12).toMatchObject({ + type: 'ack', + session: '1', + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index dbf1082cf903..7e70cbfae25b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7879,6 +7879,7 @@ __metadata: protobufjs: ^6.11.3 rimraf: ^3.0.2 typescript: 4.7.4 + usb: ^2.5.2 languageName: unknown linkType: soft @@ -9262,7 +9263,7 @@ __metadata: languageName: node linkType: hard -"@types/w3c-web-usb@npm:^1.0.5, @types/w3c-web-usb@npm:^1.0.6": +"@types/w3c-web-usb@npm:1.0.6, @types/w3c-web-usb@npm:^1.0.5, @types/w3c-web-usb@npm:^1.0.6": version: 1.0.6 resolution: "@types/w3c-web-usb@npm:1.0.6" checksum: 9f30948cb84174fa290066b08274bdfb034d38c6db0976e9a826508732fba04d81e3300bca41ea23b737f1424c51adec5ae810cdf85d5b5a158d5840914f0417 @@ -24805,6 +24806,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^4.2.0": + version: 4.3.0 + resolution: "node-addon-api@npm:4.3.0" + dependencies: + node-gyp: latest + checksum: 3de396e23cc209f539c704583e8e99c148850226f6e389a641b92e8967953713228109f919765abc1f4355e801e8f41842f96210b8d61c7dcc10a477002dcf00 + languageName: node + linkType: hard + "node-dir@npm:^0.1.10, node-dir@npm:^0.1.17": version: 0.1.17 resolution: "node-dir@npm:0.1.17" @@ -33461,6 +33471,18 @@ __metadata: languageName: node linkType: hard +"usb@npm:^2.5.2": + version: 2.5.2 + resolution: "usb@npm:2.5.2" + dependencies: + "@types/w3c-web-usb": 1.0.6 + node-addon-api: ^4.2.0 + node-gyp: latest + node-gyp-build: ^4.3.0 + checksum: 703d47c99aff5729d896c3eeb0fe4441d09a3883863551c63580224067467782380794597334d0e443f2f7bd2a2c141fdf205508366e303286dfaca5d8238de1 + languageName: node + linkType: hard + "use-callback-ref@npm:^1.3.0": version: 1.3.0 resolution: "use-callback-ref@npm:1.3.0"