From ccbb8d7e6302f096ab4aea0c00a4d327e421cb5e Mon Sep 17 00:00:00 2001 From: Martin Varmuza Date: Tue, 29 Oct 2024 08:51:06 +0100 Subject: [PATCH 01/11] feat(suite-desktop): add option to expose connect interface publicly over websocket --- packages/suite-desktop-core/package.json | 4 +- .../src/modules/trezor-connect.ts | 91 ++++++++++++++++++- yarn.lock | 2 + 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/packages/suite-desktop-core/package.json b/packages/suite-desktop-core/package.json index 1ea3ef72f15..6680055860e 100644 --- a/packages/suite-desktop-core/package.json +++ b/packages/suite-desktop-core/package.json @@ -37,7 +37,8 @@ "electron-store": "8.2.0", "electron-updater": "6.3.9", "openpgp": "^5.11.2", - "systeminformation": "^5.23.5" + "systeminformation": "^5.23.5", + "ws": "^8.18.0" }, "devDependencies": { "@currents/playwright": "^1.3.1", @@ -50,6 +51,7 @@ "@trezor/trezor-user-env-link": "workspace:*", "@trezor/type-utils": "workspace:*", "@types/electron-localshortcut": "^3.1.3", + "@types/ws": "^8.5.12", "electron": "33.1.0", "fs-extra": "^11.2.0", "glob": "^10.3.10", diff --git a/packages/suite-desktop-core/src/modules/trezor-connect.ts b/packages/suite-desktop-core/src/modules/trezor-connect.ts index 13fd7e99143..c721fa1715a 100644 --- a/packages/suite-desktop-core/src/modules/trezor-connect.ts +++ b/packages/suite-desktop-core/src/modules/trezor-connect.ts @@ -1,13 +1,94 @@ import { ipcMain } from 'electron'; +import { WebSocketServer } from 'ws'; -import TrezorConnect, { DEVICE_EVENT } from '@trezor/connect'; +import TrezorConnect, { DEVICE_EVENT, IFRAME, POPUP } from '@trezor/connect'; import { createIpcProxyHandler, IpcProxyHandlerOptions } from '@trezor/ipc-proxy'; +import { isDevEnv } from '@suite-common/suite-utils'; -import { Dependencies, mainThreadEmitter, ModuleInitBackground, ModuleInit } from './index'; +import { app } from '../typed-electron'; + +import { Dependencies, ModuleInit, ModuleInitBackground } from './index'; export const SERVICE_NAME = '@trezor/connect'; -export const initBackground: ModuleInitBackground = ({ store }: Pick) => { +const exposeConnectWs = ({ + mainThreadEmitter, +}: { + mainThreadEmitter: Dependencies['mainThreadEmitter']; +}) => { + const { logger } = global; + + const wss = new WebSocketServer({ + port: 8090, + }); + + wss.on('listening', () => { + logger.info(`${SERVICE_NAME}-ws`, 'Listening on ws://localhost:8090'); + }); + + wss.on('connection', ws => { + ws.on('error', err => { + logger.error(`${SERVICE_NAME}-ws`, err.message); + }); + + ws.on('message', async data => { + logger.debug(`${SERVICE_NAME}-ws`, data.toString()); + let message; + try { + message = JSON.parse(data.toString()); + } catch { + logger.error(`${SERVICE_NAME}-ws`, 'message is not valid JSON'); + + return; + } + + if ( + typeof message !== 'object' || + typeof message.id !== 'number' || + typeof message.type !== 'string' + ) { + logger.error(`${SERVICE_NAME}-ws`, 'message is missing required fields (id, type)'); + + return; + } + + if (message.type === POPUP.HANDSHAKE) { + ws.send(JSON.stringify({ id: message.id, type: POPUP.HANDSHAKE, payload: 'ok' })); + } else if (message.type === IFRAME.CALL) { + if (!message.payload || !message.payload.method) { + logger.error(`${SERVICE_NAME}-ws`, 'invalid message payload'); + + return; + } + + const { method, ...rest } = message.payload; + + try { + // focus renderer window + mainThreadEmitter.emit('app/show'); + // @ts-expect-error + const response = await TrezorConnect[method](rest); + ws.send( + JSON.stringify({ + ...response, + id: message.id, + }), + ); + } finally { + // blur renderer window + // mainThreadEmitter.emit(''); + } + } + }); + }); + + // todo: hmmm am I allowed to use app here directly? + app.on('before-quit', () => { + wss.close(); + }); +}; + +export const initBackground: ModuleInitBackground = ({ mainThreadEmitter, store }) => { const { logger } = global; logger.info(SERVICE_NAME, `Starting service`); @@ -28,6 +109,10 @@ export const initBackground: ModuleInitBackground = ({ store }: Pick Date: Tue, 29 Oct 2024 08:57:34 +0100 Subject: [PATCH 02/11] feat(connect): add core-in-desktop mode --- .../src/impl/core-in-suite-desktop.ts | 192 ++++++++++++++++++ packages/connect-web/src/index.ts | 9 +- packages/connect/src/types/settings.ts | 2 +- 3 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 packages/connect-web/src/impl/core-in-suite-desktop.ts diff --git a/packages/connect-web/src/impl/core-in-suite-desktop.ts b/packages/connect-web/src/impl/core-in-suite-desktop.ts new file mode 100644 index 00000000000..c677eacf9e3 --- /dev/null +++ b/packages/connect-web/src/impl/core-in-suite-desktop.ts @@ -0,0 +1,192 @@ +import EventEmitter from 'events'; + +// NOTE: @trezor/connect part is intentionally not imported from the index so we do include the whole library. +import { + IFRAME, + UiResponseEvent, + CallMethodPayload, + CallMethodAnyResponse, + POPUP, +} from '@trezor/connect/src/events'; +import * as ERRORS from '@trezor/connect/src/constants/errors'; +import type { + ConnectSettings, + ConnectSettingsPublic, + ConnectSettingsWeb, + Manifest, + Response, +} from '@trezor/connect/src/types'; +import { ConnectFactoryDependencies, factory } from '@trezor/connect/src/factory'; +import { Login } from '@trezor/connect/src/types/api/requestLogin'; +import { createDeferred, createDeferredManager, DeferredManager } from '@trezor/utils'; + +import { parseConnectSettings } from '../connectSettings'; + +/** + * Base class for CoreInPopup methods for TrezorConnect factory. + * This implementation is directly used here in connect-web, but it is also extended in connect-webextension. + */ +export class CoreInSuiteDesktop implements ConnectFactoryDependencies { + public eventEmitter = new EventEmitter(); + protected _settings: ConnectSettings; + private ws?: WebSocket; + private readonly messages: DeferredManager; + + public constructor() { + this._settings = parseConnectSettings(); + this.messages = createDeferredManager(); + } + + public manifest(data: Manifest) { + this._settings = parseConnectSettings({ + ...this._settings, + manifest: data, + }); + } + + public dispose() { + this.eventEmitter.removeAllListeners(); + this._settings = parseConnectSettings(); + this.ws?.close(); + + return Promise.resolve(undefined); + } + + public cancel(_error?: string) {} + + private async handshake() { + const { promise, promiseId } = this.messages.create(1000); + this.ws?.send( + JSON.stringify({ + id: promiseId, + type: POPUP.HANDSHAKE, + }), + ); + try { + await promise; + } catch (err) { + console.error(err); + throw new Error('Handshake timed out'); + } + } + + public async init(settings: Partial = {}): Promise { + const newSettings = parseConnectSettings({ + ...this._settings, + ...settings, + }); + + // defaults + if (!newSettings.transports?.length) { + newSettings.transports = ['BridgeTransport', 'WebUsbTransport']; + } + this._settings = newSettings; + + this.ws?.close(); + const wsOpen = createDeferred(1000); + this.ws = new WebSocket('ws://localhost:21335/connect-ws'); + this.ws.addEventListener('opened', () => { + wsOpen.resolve(); + }); + this.ws.addEventListener('error', () => { + wsOpen.reject(new Error('WebSocket error')); + this.messages.rejectAll(new Error('WebSocket error')); + }); + this.ws.addEventListener('message', (event: WebSocketEventMap['message']) => { + try { + const data = JSON.parse(event.data); + this.messages.resolve(data.id, data); + } catch { + // Some undefined message format + } + }); + this.ws.addEventListener('close', () => { + wsOpen.reject(new Error('WebSocket closed')); + this.messages.rejectAll(new Error('WebSocket closed')); + }); + + // Wait for the connection to be opened + if (this.ws.readyState !== WebSocket.OPEN) { + // There is some glitch that when reconnecting the open event doesn't fire + // So we do this as a workaround + setTimeout(() => { + if (this.ws?.readyState === WebSocket.OPEN) { + wsOpen.resolve(); + } + }, 500); + await wsOpen.promise; + } + + return await this.handshake(); + } + + /** + * 1. opens popup + * 2. sends request to popup where the request is handled by core + * 3. returns response + */ + public async call(params: CallMethodPayload): Promise { + try { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + await this.init(); + } + await this.handshake(); + + const { promise, promiseId } = this.messages.create(); + + this.ws?.send( + JSON.stringify({ + id: promiseId, + type: IFRAME.CALL, + payload: params, + }), + ); + + return promise; + } catch (err) { + return { + success: false, + payload: { + error: err.message, + }, + }; + } + } + // this shouldn't be needed, ui response should be handled in suite-desktop + uiResponse(_response: UiResponseEvent) { + throw ERRORS.TypedError('Method_InvalidPackage'); + } + + // todo: not supported yet + requestLogin(): Response { + throw ERRORS.TypedError('Method_InvalidPackage'); + } + + // todo: not needed, only because of types + disableWebUSB() { + throw ERRORS.TypedError('Method_InvalidPackage'); + } + + // todo: not needed, only because of types + requestWebUSBDevice() { + throw ERRORS.TypedError('Method_InvalidPackage'); + } + + // todo: not needed, only because of types + renderWebUSBButton() {} +} + +const impl = new CoreInSuiteDesktop(); + +// Exported to enable using directly +export const TrezorConnect = factory({ + // Bind all methods due to shadowing `this` + eventEmitter: impl.eventEmitter, + init: impl.init.bind(impl), + call: impl.call.bind(impl), + manifest: impl.manifest.bind(impl), + requestLogin: impl.requestLogin.bind(impl), + uiResponse: impl.uiResponse.bind(impl), + cancel: impl.cancel.bind(impl), + dispose: impl.dispose.bind(impl), +}); diff --git a/packages/connect-web/src/index.ts b/packages/connect-web/src/index.ts index 052bf1c0994..d251a5cf89f 100644 --- a/packages/connect-web/src/index.ts +++ b/packages/connect-web/src/index.ts @@ -4,6 +4,7 @@ import type { ConnectSettingsPublic, ConnectSettingsWeb } from '@trezor/connect' import { CoreInIframe } from './impl/core-in-iframe'; import { CoreInPopup } from './impl/core-in-popup'; +import { CoreInSuiteDesktop } from './impl/core-in-suite-desktop'; import { getEnv } from './connectSettings'; const IFRAME_ERRORS = ['Init_IframeBlocked', 'Init_IframeTimeout', 'Transport_Missing']; @@ -15,7 +16,7 @@ type ConnectWebExtraMethods = { }; const impl = new TrezorConnectDynamic< - 'iframe' | 'core-in-popup', + 'iframe' | 'core-in-popup' | 'core-in-suite-desktop', ConnectSettingsWeb, ConnectFactoryDependencies & ConnectWebExtraMethods >({ @@ -28,12 +29,18 @@ const impl = new TrezorConnectDynamic< type: 'core-in-popup', impl: new CoreInPopup(), }, + { + type: 'core-in-suite-desktop', + impl: new CoreInSuiteDesktop(), + }, ], getInitTarget: (settings: Partial) => { if (settings.coreMode === 'iframe') { return 'iframe'; } else if (settings.coreMode === 'popup') { return 'core-in-popup'; + } else if (settings.coreMode === 'suite-desktop') { + return 'core-in-suite-desktop'; } else { if (settings.coreMode && settings.coreMode !== 'auto') { console.warn(`Invalid coreMode: ${settings.coreMode}`); diff --git a/packages/connect/src/types/settings.ts b/packages/connect/src/types/settings.ts index 867cc44f6a9..6d8f674430c 100644 --- a/packages/connect/src/types/settings.ts +++ b/packages/connect/src/types/settings.ts @@ -51,7 +51,7 @@ export interface ConnectSettingsInternal { export interface ConnectSettingsWeb { hostLabel?: string; hostIcon?: string; - coreMode?: 'auto' | 'popup' | 'iframe' | 'deeplink'; + coreMode?: 'auto' | 'popup' | 'iframe' | 'deeplink' | 'suite-desktop'; } export interface ConnectSettingsWebextension { /** _extendWebextensionLifetime features makes the service worker in @trezor/connect-webextension stay alive longer */ From 1956233a8cbee6997fbe6be043e9f9b961e7faa7 Mon Sep 17 00:00:00 2001 From: Martin Varmuza Date: Tue, 29 Oct 2024 08:57:55 +0100 Subject: [PATCH 03/11] feat(connect-explorer): add option to use core-in-desktop mode from settings page --- packages/connect-explorer/src/components/Settings.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/connect-explorer/src/components/Settings.tsx b/packages/connect-explorer/src/components/Settings.tsx index af00274eb73..86846079564 100644 --- a/packages/connect-explorer/src/components/Settings.tsx +++ b/packages/connect-explorer/src/components/Settings.tsx @@ -57,6 +57,7 @@ export const Settings = () => { { value: 'iframe', label: 'Iframe' }, { value: 'popup', label: 'Popup' }, ...(isBetaOnly ? [{ value: 'deeplink', label: 'Deeplink (mobile)' }] : []), + ...(isBetaOnly ? [{ value: 'suite-desktop', label: 'Suite desktop' }] : []), ], }, { From 8af463c72073e3524bef645343dad867f1d0f97b Mon Sep 17 00:00:00 2001 From: Tomas Martykan Date: Mon, 11 Nov 2024 15:51:27 +0100 Subject: [PATCH 04/11] chore(suite-desktop-core): separate connect ws logic, combine with existing http receiver --- packages/suite-desktop-core/src/app.ts | 6 +- .../suite-desktop-core/src/libs/connect-ws.ts | 92 +++++++++++++++++++ .../src/modules/http-receiver.ts | 10 +- .../suite-desktop-core/src/modules/index.ts | 23 +++-- .../src/modules/trezor-connect.ts | 89 +----------------- 5 files changed, 122 insertions(+), 98 deletions(-) create mode 100644 packages/suite-desktop-core/src/libs/connect-ws.ts diff --git a/packages/suite-desktop-core/src/app.ts b/packages/suite-desktop-core/src/app.ts index bdba56b1342..9bb79e4e689 100644 --- a/packages/suite-desktop-core/src/app.ts +++ b/packages/suite-desktop-core/src/app.ts @@ -144,7 +144,7 @@ const init = async () => { interceptor, mainThreadEmitter, }); - await loadBackgroundModules(undefined); + const backgroundModulesResponse = await loadBackgroundModules(undefined); // Daemon mode with no UI const { wasOpenedAtLogin } = app.getLoginItemSettings(); @@ -222,9 +222,9 @@ const init = async () => { // create handler for handshake/load-modules const loadModulesResponse = (clientData: HandshakeClient) => loadModules(clientData) - .then(payload => ({ + .then(modulesResponse => ({ success: true as const, - payload, + payload: { ...modulesResponse, ...backgroundModulesResponse }, })) .catch(err => ({ success: false as const, diff --git a/packages/suite-desktop-core/src/libs/connect-ws.ts b/packages/suite-desktop-core/src/libs/connect-ws.ts new file mode 100644 index 00000000000..edfeba121af --- /dev/null +++ b/packages/suite-desktop-core/src/libs/connect-ws.ts @@ -0,0 +1,92 @@ +import { WebSocketServer } from 'ws'; + +import TrezorConnect, { IFRAME, POPUP } from '@trezor/connect'; + +import { createHttpReceiver } from './http-receiver'; +import { Dependencies } from '../modules'; + +const LOG_PREFIX = 'connect-ws'; + +export const exposeConnectWs = ({ + mainThreadEmitter, + httpReceiver, +}: { + mainThreadEmitter: Dependencies['mainThreadEmitter']; + httpReceiver: ReturnType; +}) => { + const { logger } = global; + + const wss = new WebSocketServer({ + noServer: true, + }); + + wss.on('listening', () => { + logger.info(LOG_PREFIX, 'Websocket server is listening'); + }); + + wss.on('connection', ws => { + ws.on('error', err => { + logger.error(LOG_PREFIX, err.message); + }); + + ws.on('message', async data => { + logger.debug(LOG_PREFIX, data.toString()); + let message; + try { + message = JSON.parse(data.toString()); + } catch { + logger.error(LOG_PREFIX, 'message is not valid JSON'); + + return; + } + + if ( + typeof message !== 'object' || + typeof message.id !== 'number' || + typeof message.type !== 'string' + ) { + logger.error(LOG_PREFIX, 'message is missing required fields (id, type)'); + + return; + } + + if (message.type === POPUP.HANDSHAKE) { + ws.send(JSON.stringify({ id: message.id, type: POPUP.HANDSHAKE, payload: 'ok' })); + } else if (message.type === IFRAME.CALL) { + if (!message.payload || !message.payload.method) { + logger.error(LOG_PREFIX, 'invalid message payload'); + + return; + } + + const { method, ...rest } = message.payload; + + try { + // focus renderer window + mainThreadEmitter.emit('app/show'); + // @ts-expect-error + const response = await TrezorConnect[method](rest); + ws.send( + JSON.stringify({ + ...response, + id: message.id, + }), + ); + } finally { + // blur renderer window + // mainThreadEmitter.emit(''); + } + } + }); + }); + + httpReceiver.server.on('upgrade', (request, socket, head) => { + if (!request?.url) return; + const { pathname } = new URL(request.url, 'http://localhost'); + if (pathname === '/connect-ws') { + wss.handleUpgrade(request, socket, head, ws => { + wss.emit('connection', ws, request); + }); + } + }); +}; diff --git a/packages/suite-desktop-core/src/modules/http-receiver.ts b/packages/suite-desktop-core/src/modules/http-receiver.ts index b89c3955cc8..90a147d6977 100644 --- a/packages/suite-desktop-core/src/modules/http-receiver.ts +++ b/packages/suite-desktop-core/src/modules/http-receiver.ts @@ -2,15 +2,17 @@ * Local web server for handling requests to app */ import { validateIpcMessage } from '@trezor/ipc-proxy'; +import { isDevEnv } from '@suite-common/suite-utils'; import { app, ipcMain } from '../typed-electron'; import { createHttpReceiver } from '../libs/http-receiver'; +import { exposeConnectWs } from '../libs/connect-ws'; -import type { ModuleInit } from './index'; +import type { ModuleInitBackground } from './index'; export const SERVICE_NAME = 'http-receiver'; -export const init: ModuleInit = ({ mainWindowProxy }) => { +export const initBackground: ModuleInitBackground = ({ mainWindowProxy, mainThreadEmitter }) => { const { logger } = global; let httpReceiver: ReturnType | null = null; @@ -52,6 +54,10 @@ export const init: ModuleInit = ({ mainWindowProxy }) => { return receiver.getRouteAddress(pathname); }); + if (app.commandLine.hasSwitch('expose-connect-ws') || isDevEnv) { + exposeConnectWs({ mainThreadEmitter, httpReceiver: receiver }); + } + logger.info(SERVICE_NAME, 'Starting server'); await receiver.start(); diff --git a/packages/suite-desktop-core/src/modules/index.ts b/packages/suite-desktop-core/src/modules/index.ts index 36df71b3497..078cd9bc6eb 100644 --- a/packages/suite-desktop-core/src/modules/index.ts +++ b/packages/suite-desktop-core/src/modules/index.ts @@ -51,7 +51,6 @@ const MODULES: Module[] = [ externalLinks, windowControls, theme, - httpReceiverModule, metadata, customProtocols, autoUpdater, @@ -68,7 +67,7 @@ const MODULES: Module[] = [ ...(isDevEnv ? [] : [csp, fileProtocol]), ]; -const MODULES_BACKGROUND: ModuleBackground[] = [bridge, trezorConnect, tray]; +const MODULES_BACKGROUND: ModuleBackground[] = [bridge, trezorConnect, httpReceiverModule, tray]; // define events internally sent between modules interface MainThreadMessages { @@ -205,17 +204,29 @@ export const initModules = (dependencies: Dependencies) => { [customProtocols.SERVICE_NAME]: protocol, [autoUpdater.SERVICE_NAME]: desktopUpdate, [userData.SERVICE_NAME]: { dir: userDir }, - [httpReceiverModule.SERVICE_NAME]: { url: httpReceiver }, }) => ({ protocol, desktopUpdate, paths: { userDir, binDir: path.join(global.resourcesPath, 'bin') }, - urls: { httpReceiver }, }), ); return { loadModules, quitModules }; }; -export const initBackgroundModules = (dependencies: Dependencies) => - initModulesInner(dependencies, true, MODULES_BACKGROUND); +export const initBackgroundModules = (dependencies: Dependencies) => { + const { loadModules: loadModulesInner, quitModules } = initModulesInner( + dependencies, + true, + MODULES_BACKGROUND, + ); + + const loadModules = (handshake: HandshakeClient) => + loadModulesInner(handshake).then( + ({ [httpReceiverModule.SERVICE_NAME]: { url: httpReceiver } }) => ({ + urls: { httpReceiver }, + }), + ); + + return { loadModules, quitModules }; +}; diff --git a/packages/suite-desktop-core/src/modules/trezor-connect.ts b/packages/suite-desktop-core/src/modules/trezor-connect.ts index c721fa1715a..eb8c1d375cf 100644 --- a/packages/suite-desktop-core/src/modules/trezor-connect.ts +++ b/packages/suite-desktop-core/src/modules/trezor-connect.ts @@ -1,93 +1,12 @@ import { ipcMain } from 'electron'; -import { WebSocketServer } from 'ws'; -import TrezorConnect, { DEVICE_EVENT, IFRAME, POPUP } from '@trezor/connect'; +import TrezorConnect, { DEVICE_EVENT } from '@trezor/connect'; import { createIpcProxyHandler, IpcProxyHandlerOptions } from '@trezor/ipc-proxy'; -import { isDevEnv } from '@suite-common/suite-utils'; -import { app } from '../typed-electron'; - -import { Dependencies, ModuleInit, ModuleInitBackground } from './index'; +import { ModuleInit, ModuleInitBackground } from './index'; export const SERVICE_NAME = '@trezor/connect'; -const exposeConnectWs = ({ - mainThreadEmitter, -}: { - mainThreadEmitter: Dependencies['mainThreadEmitter']; -}) => { - const { logger } = global; - - const wss = new WebSocketServer({ - port: 8090, - }); - - wss.on('listening', () => { - logger.info(`${SERVICE_NAME}-ws`, 'Listening on ws://localhost:8090'); - }); - - wss.on('connection', ws => { - ws.on('error', err => { - logger.error(`${SERVICE_NAME}-ws`, err.message); - }); - - ws.on('message', async data => { - logger.debug(`${SERVICE_NAME}-ws`, data.toString()); - let message; - try { - message = JSON.parse(data.toString()); - } catch { - logger.error(`${SERVICE_NAME}-ws`, 'message is not valid JSON'); - - return; - } - - if ( - typeof message !== 'object' || - typeof message.id !== 'number' || - typeof message.type !== 'string' - ) { - logger.error(`${SERVICE_NAME}-ws`, 'message is missing required fields (id, type)'); - - return; - } - - if (message.type === POPUP.HANDSHAKE) { - ws.send(JSON.stringify({ id: message.id, type: POPUP.HANDSHAKE, payload: 'ok' })); - } else if (message.type === IFRAME.CALL) { - if (!message.payload || !message.payload.method) { - logger.error(`${SERVICE_NAME}-ws`, 'invalid message payload'); - - return; - } - - const { method, ...rest } = message.payload; - - try { - // focus renderer window - mainThreadEmitter.emit('app/show'); - // @ts-expect-error - const response = await TrezorConnect[method](rest); - ws.send( - JSON.stringify({ - ...response, - id: message.id, - }), - ); - } finally { - // blur renderer window - // mainThreadEmitter.emit(''); - } - } - }); - }); - - // todo: hmmm am I allowed to use app here directly? - app.on('before-quit', () => { - wss.close(); - }); -}; - export const initBackground: ModuleInitBackground = ({ mainThreadEmitter, store }) => { const { logger } = global; logger.info(SERVICE_NAME, `Starting service`); @@ -109,10 +28,6 @@ export const initBackground: ModuleInitBackground = ({ mainThreadEmitter, store const response = await TrezorConnect[method](...params); await setProxy(true); - if (app.commandLine.hasSwitch('expose-connect-ws') || isDevEnv) { - exposeConnectWs({ mainThreadEmitter }); - } - return response; } From 8ee35006c72b329ceb9255223372c741c8ec0ca5 Mon Sep 17 00:00:00 2001 From: Tomas Martykan Date: Tue, 12 Nov 2024 15:13:06 +0100 Subject: [PATCH 05/11] chore(suite): move connect popup handling to web --- packages/suite-desktop-api/src/api.ts | 10 +++ packages/suite-desktop-api/src/factory.ts | 4 ++ packages/suite-desktop-api/src/messages.ts | 12 ++++ packages/suite-desktop-api/src/validation.ts | 1 + .../suite-desktop-core/src/libs/connect-ws.ts | 70 +++++++++++++++---- .../src/modules/http-receiver.ts | 2 +- .../suite/src/actions/suite/initAction.ts | 7 +- suite-common/connect-init/package.json | 1 + .../connect-init/src/connectInitThunks.ts | 70 ++++++++++++++++++- suite-common/connect-init/tsconfig.json | 3 + yarn.lock | 1 + 11 files changed, 163 insertions(+), 18 deletions(-) diff --git a/packages/suite-desktop-api/src/api.ts b/packages/suite-desktop-api/src/api.ts index 4e8974cc5d8..a00c278cccf 100644 --- a/packages/suite-desktop-api/src/api.ts +++ b/packages/suite-desktop-api/src/api.ts @@ -16,6 +16,8 @@ import { BridgeSettings, TorSettings, TraySettings, + ConnectPopupResponse, + ConnectPopupCall, } from './messages'; // Event messages from renderer to main process @@ -72,6 +74,9 @@ export interface RendererChannels { 'tray/settings': TraySettings; 'handshake/event': HandshakeEvent; + + // connect + 'connect-popup/call': ConnectPopupCall; } // Invocation from renderer process @@ -99,6 +104,8 @@ export interface InvokeChannels { 'app/auto-start/is-enabled': () => InvokeResult; 'tray/change-settings': (payload: TraySettings) => InvokeResult; 'tray/get-settings': () => InvokeResult; + 'connect-popup/ready': () => void; + 'connect-popup/response': (response: ConnectPopupResponse) => void; } type DesktopApiListener = ListenerMethod; @@ -161,4 +168,7 @@ export interface DesktopApi { // Tray changeTraySettings: DesktopApiInvoke<'tray/change-settings'>; getTraySettings: DesktopApiInvoke<'tray/get-settings'>; + // Connect popup + connectPopupReady: DesktopApiInvoke<'connect-popup/ready'>; + connectPopupResponse: DesktopApiInvoke<'connect-popup/response'>; } diff --git a/packages/suite-desktop-api/src/factory.ts b/packages/suite-desktop-api/src/factory.ts index 4c04d51e30f..82b2925e484 100644 --- a/packages/suite-desktop-api/src/factory.ts +++ b/packages/suite-desktop-api/src/factory.ts @@ -177,5 +177,9 @@ export const factory = >( return Promise.resolve({ success: false, error: 'invalid params' }); }, getTraySettings: () => ipcRenderer.invoke('tray/get-settings'), + + // Connect popup + connectPopupReady: () => ipcRenderer.invoke('connect-popup/ready'), + connectPopupResponse: response => ipcRenderer.invoke('connect-popup/response', response), }; }; diff --git a/packages/suite-desktop-api/src/messages.ts b/packages/suite-desktop-api/src/messages.ts index 2e14ffd1fa3..350c187b45c 100644 --- a/packages/suite-desktop-api/src/messages.ts +++ b/packages/suite-desktop-api/src/messages.ts @@ -126,3 +126,15 @@ export type InvokeResult = ExtractUndefined extends undefined ? { success: true; payload?: Payload } | { success: false; error: string; code?: string } : { success: true; payload: Payload } | { success: false; error: string; code?: string }; + +export type ConnectPopupCall = { + id: number; + method: string; + payload: any; +}; + +export type ConnectPopupResponse = { + id: number; + success: boolean; + payload: any; +}; diff --git a/packages/suite-desktop-api/src/validation.ts b/packages/suite-desktop-api/src/validation.ts index 095115c4fe3..277c2e5de08 100644 --- a/packages/suite-desktop-api/src/validation.ts +++ b/packages/suite-desktop-api/src/validation.ts @@ -44,5 +44,6 @@ const validChannels: Array = [ 'bridge/status', 'bridge/settings', 'tray/settings', + 'connect-popup/call', ]; export const isValidChannel = (channel: any) => validChannels.includes(channel); diff --git a/packages/suite-desktop-core/src/libs/connect-ws.ts b/packages/suite-desktop-core/src/libs/connect-ws.ts index edfeba121af..c592f6a9951 100644 --- a/packages/suite-desktop-core/src/libs/connect-ws.ts +++ b/packages/suite-desktop-core/src/libs/connect-ws.ts @@ -1,6 +1,8 @@ import { WebSocketServer } from 'ws'; +import { ipcMain } from 'electron'; -import TrezorConnect, { IFRAME, POPUP } from '@trezor/connect'; +import { IFRAME, POPUP } from '@trezor/connect'; +import { createDeferred, Deferred } from '@trezor/utils'; import { createHttpReceiver } from './http-receiver'; import { Dependencies } from '../modules'; @@ -9,12 +11,16 @@ const LOG_PREFIX = 'connect-ws'; export const exposeConnectWs = ({ mainThreadEmitter, + mainWindowProxy, httpReceiver, }: { mainThreadEmitter: Dependencies['mainThreadEmitter']; + mainWindowProxy: Dependencies['mainWindowProxy']; httpReceiver: ReturnType; }) => { const { logger } = global; + const messages: Record> = {}; + let appInit: Deferred | undefined; const wss = new WebSocketServer({ noServer: true, @@ -61,21 +67,35 @@ export const exposeConnectWs = ({ const { method, ...rest } = message.payload; - try { - // focus renderer window - mainThreadEmitter.emit('app/show'); - // @ts-expect-error - const response = await TrezorConnect[method](rest); - ws.send( - JSON.stringify({ - ...response, - id: message.id, - }), - ); - } finally { - // blur renderer window - // mainThreadEmitter.emit(''); + messages[message.id] = createDeferred(); + + // focus renderer window + mainThreadEmitter.emit('app/show'); + + // check window exists, if not wait for it to be created + if (!mainWindowProxy.getInstance()) { + logger.info(LOG_PREFIX, 'waiting for window to start'); + appInit = createDeferred(10000); + await appInit.promise; + appInit = undefined; } + + // send call to renderer + mainWindowProxy.getInstance()?.webContents.send('connect-popup/call', { + id: message.id, + method, + payload: rest, + }); + + // wait for response + const response = await messages[message.id].promise; + + ws.send( + JSON.stringify({ + ...response, + id: message.id, + }), + ); } }); }); @@ -89,4 +109,24 @@ export const exposeConnectWs = ({ }); } }); + + ipcMain.handle('connect-popup/response', (_, response) => { + logger.info(LOG_PREFIX, 'received response from popup ' + JSON.stringify(response)); + if (!response || typeof response.id !== 'number') { + logger.error(LOG_PREFIX, 'invalid response from popup'); + + return; + } + + if (!messages[response.id]) { + logger.error(LOG_PREFIX, 'no deferred message found'); + + return; + } + + messages[response.id].resolve(response); + }); + ipcMain.handle('connect-popup/ready', () => { + appInit?.resolve(); + }); }; diff --git a/packages/suite-desktop-core/src/modules/http-receiver.ts b/packages/suite-desktop-core/src/modules/http-receiver.ts index 90a147d6977..53206a24930 100644 --- a/packages/suite-desktop-core/src/modules/http-receiver.ts +++ b/packages/suite-desktop-core/src/modules/http-receiver.ts @@ -55,7 +55,7 @@ export const initBackground: ModuleInitBackground = ({ mainWindowProxy, mainThre }); if (app.commandLine.hasSwitch('expose-connect-ws') || isDevEnv) { - exposeConnectWs({ mainThreadEmitter, httpReceiver: receiver }); + exposeConnectWs({ mainThreadEmitter, httpReceiver: receiver, mainWindowProxy }); } logger.info(SERVICE_NAME, 'Starting server'); diff --git a/packages/suite/src/actions/suite/initAction.ts b/packages/suite/src/actions/suite/initAction.ts index 4afb3b83757..520723204ea 100644 --- a/packages/suite/src/actions/suite/initAction.ts +++ b/packages/suite/src/actions/suite/initAction.ts @@ -113,6 +113,11 @@ export const init = () => async (dispatch: Dispatch, getState: GetState) => { // 13. start fetching staking data if needed, does need to be waited dispatch(periodicCheckStakeDataThunk()); - // 14. backend connected, suite is ready to use + // 14. init connect popup handler + if (isDesktop()) { + dispatch(trezorConnectActions.connectPopupInitThunk()); + } + + // 15. backend connected, suite is ready to use dispatch(onSuiteReady()); }; diff --git a/suite-common/connect-init/package.json b/suite-common/connect-init/package.json index 6311e35a213..e7d9c80d472 100644 --- a/suite-common/connect-init/package.json +++ b/suite-common/connect-init/package.json @@ -19,6 +19,7 @@ "@suite-common/wallet-core": "workspace:*", "@trezor/connect": "workspace:*", "@trezor/env-utils": "workspace:^", + "@trezor/suite-desktop-api": "workspace:*", "@trezor/utils": "workspace:*" }, "devDependencies": { diff --git a/suite-common/connect-init/src/connectInitThunks.ts b/suite-common/connect-init/src/connectInitThunks.ts index 673f8ed06f0..1c4d1d32f54 100644 --- a/suite-common/connect-init/src/connectInitThunks.ts +++ b/suite-common/connect-init/src/connectInitThunks.ts @@ -3,13 +3,16 @@ import TrezorConnect, { BLOCKCHAIN_EVENT, DEVICE, DEVICE_EVENT, + ERRORS, TRANSPORT_EVENT, UI_EVENT, } from '@trezor/connect'; import { getSynchronize } from '@trezor/utils'; -import { deviceConnectThunks } from '@suite-common/wallet-core'; +import { deviceConnectThunks, selectDevice } from '@suite-common/wallet-core'; import { resolveStaticPath } from '@suite-common/suite-utils'; import { isDesktop, isNative } from '@trezor/env-utils'; +import { desktopApi } from '@trezor/suite-desktop-api'; +import { serializeError, TypedError } from '@trezor/connect/src/constants/errors'; import { cardanoConnectPatch } from './cardanoConnectPatch'; @@ -166,3 +169,68 @@ export const connectInitThunk = createThunk( } }, ); + +export const connectPopupCallThunk = createThunk( + `${CONNECT_INIT_MODULE}/callThunk`, + async ( + { + id, + method, + payload, + }: { + id: number; + method: string; + payload: any; + }, + { dispatch, getState, extra }, + ) => { + try { + const device = selectDevice(getState()); + + if (!device) { + console.error('Device not found'); + // Need to select device first + dispatch(extra.thunks.openSwitchDeviceDialog()); + + // TODO: wait for device selection and continue + throw ERRORS.TypedError('Device_NotFound'); + } + + // TODO: go to some popup route + + // @ts-expect-error: method is dynamic + const response = await TrezorConnect[method]({ + device: { + path: device.path, + instance: device.instance, + state: device.state, + }, + ...payload, + }); + + desktopApi.connectPopupResponse({ + ...response, + id, + }); + } catch (error) { + desktopApi.connectPopupResponse({ + success: false, + payload: serializeError(error), + id, + }); + } + }, +); + +export const connectPopupInitThunk = createThunk( + `${CONNECT_INIT_MODULE}/initPopupThunk`, + (_, { dispatch }) => { + if (!desktopApi.available) { + return; + } + desktopApi.on('connect-popup/call', params => { + dispatch(connectPopupCallThunk(params)); + }); + desktopApi.connectPopupReady(); + }, +); diff --git a/suite-common/connect-init/tsconfig.json b/suite-common/connect-init/tsconfig.json index dfa2b7749bc..ef966723436 100644 --- a/suite-common/connect-init/tsconfig.json +++ b/suite-common/connect-init/tsconfig.json @@ -9,6 +9,9 @@ { "path": "../wallet-core" }, { "path": "../../packages/connect" }, { "path": "../../packages/env-utils" }, + { + "path": "../../packages/suite-desktop-api" + }, { "path": "../../packages/utils" } ] } diff --git a/yarn.lock b/yarn.lock index f8cd858db2a..a71ef7aa416 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8529,6 +8529,7 @@ __metadata: "@suite-common/wallet-core": "workspace:*" "@trezor/connect": "workspace:*" "@trezor/env-utils": "workspace:^" + "@trezor/suite-desktop-api": "workspace:*" "@trezor/utils": "workspace:*" redux-mock-store: "npm:^1.5.4" redux-thunk: "npm:^2.4.2" From 68daea4a0d39e08aed941b86aa12f0acc100a61e Mon Sep 17 00:00:00 2001 From: Tomas Martykan Date: Wed, 13 Nov 2024 14:54:00 +0100 Subject: [PATCH 06/11] feat(suite): connect popup modals --- packages/suite-desktop-core/src/app.ts | 8 +++- .../modals/ReduxModal/ConfirmAddressModal.tsx | 9 ++++- .../modals/ReduxModal/ConfirmXpubModal.tsx | 8 +++- .../TransactionReviewModalContent.tsx | 7 +++- .../UserContextModal/ConnectPopupModal.tsx | 37 +++++++++++++++++++ .../UserContextModal/UserContextModal.tsx | 9 +++++ .../src/components/suite/modals/index.tsx | 1 + .../connect-init/src/connectInitThunks.ts | 16 ++++++-- suite-common/suite-types/src/modal.ts | 5 +++ 9 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/ConnectPopupModal.tsx diff --git a/packages/suite-desktop-core/src/app.ts b/packages/suite-desktop-core/src/app.ts index 9bb79e4e689..019b8d5fb40 100644 --- a/packages/suite-desktop-core/src/app.ts +++ b/packages/suite-desktop-core/src/app.ts @@ -208,8 +208,12 @@ const init = async () => { app.dock?.show(); if (isMacOs()) app.show(); - if (!mainWindow.isVisible()) mainWindow.show(); - if (mainWindow.isMinimized()) mainWindow.restore(); + //if (!mainWindow.isVisible()) + mainWindow.show(); + //if (mainWindow.isMinimized()) + mainWindow.restore(); + app.focus(); + mainWindow.moveTop(); mainWindow.focus(); }; app.on('second-instance', reactivateWindow); diff --git a/packages/suite/src/components/suite/modals/ReduxModal/ConfirmAddressModal.tsx b/packages/suite/src/components/suite/modals/ReduxModal/ConfirmAddressModal.tsx index 642078cf3a8..5888d88e35d 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/ConfirmAddressModal.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/ConfirmAddressModal.tsx @@ -1,5 +1,7 @@ import { useCallback } from 'react'; +import { selectDevice } from '@suite-common/wallet-core'; + import { showAddress } from 'src/actions/wallet/receiveActions'; import { Translation } from 'src/components/suite'; import { @@ -11,12 +13,15 @@ import { useCoinmarketInfo } from 'src/hooks/wallet/coinmarket/useCoinmarketInfo import { selectAccountIncludingChosenInCoinmarket } from 'src/reducers/wallet/selectedAccountReducer'; import { cryptoIdToNetworkSymbol } from 'src/utils/wallet/coinmarket/coinmarketUtils'; +import { ConfirmActionModal } from './DeviceContextModal/ConfirmActionModal'; + interface ConfirmAddressModalProps extends Pick { addressPath: string; } export const ConfirmAddressModal = ({ addressPath, value, ...props }: ConfirmAddressModalProps) => { + const device = useSelector(selectDevice); const account = useSelector(selectAccountIncludingChosenInCoinmarket); const { modalCryptoId } = useSelector(state => state.wallet.coinmarket); const displayMode = useDisplayMode({ type: 'address' }); @@ -27,7 +32,9 @@ export const ConfirmAddressModal = ({ addressPath, value, ...props }: ConfirmAdd [addressPath, value], ); - if (!account) return null; + if (!device) return null; + // TODO: special case for Connect Popup + if (!account) return ; const getHeading = () => { if (modalCryptoId) { diff --git a/packages/suite/src/components/suite/modals/ReduxModal/ConfirmXpubModal.tsx b/packages/suite/src/components/suite/modals/ReduxModal/ConfirmXpubModal.tsx index 1cd75690bb5..f20cc30f4a3 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/ConfirmXpubModal.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/ConfirmXpubModal.tsx @@ -1,3 +1,5 @@ +import { selectDevice } from '@suite-common/wallet-core'; + import { Translation } from 'src/components/suite'; import { showXpub } from 'src/actions/wallet/publicKeyActions'; import { selectSelectedAccount } from 'src/reducers/wallet/selectedAccountReducer'; @@ -6,14 +8,18 @@ import { useSelector } from 'src/hooks/suite'; import { DisplayMode } from 'src/types/suite'; import { ConfirmValueModal, ConfirmValueModalProps } from './ConfirmValueModal/ConfirmValueModal'; +import { ConfirmActionModal } from './DeviceContextModal/ConfirmActionModal'; export const ConfirmXpubModal = ( props: Pick, ) => { + const device = useSelector(selectDevice); const account = useSelector(selectSelectedAccount); const { accountLabel } = useSelector(selectLabelingDataForSelectedAccount); - if (!account) return null; + if (!device) return null; + // TODO: special case for Connect Popup + if (!account) return ; const xpub = account.descriptorChecksum !== undefined diff --git a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModalContent.tsx b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModalContent.tsx index c2d96157f4d..81491dbc229 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModalContent.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModalContent.tsx @@ -31,6 +31,7 @@ import { selectAccountIncludingChosenInCoinmarket } from 'src/reducers/wallet/se import { TransactionReviewSummary } from './TransactionReviewSummary'; import { TransactionReviewOutputList } from './TransactionReviewOutputList/TransactionReviewOutputList'; import { TransactionReviewEvmExplanation } from './TransactionReviewEvmExplanation'; +import { ConfirmActionModal } from '../DeviceContextModal/ConfirmActionModal'; const StyledModal = styled(Modal)` ${Modal.Body} { @@ -88,8 +89,10 @@ export const TransactionReviewModalContent = ({ selectSendFormReviewButtonRequestsCount(state, account?.symbol, decreaseOutputId), ); - if (!account || !device || !precomposedTx || !precomposedForm) { - return null; + if (!device) return null; + if (!account || !precomposedTx || !precomposedForm) { + // TODO: special case for Connect Popup + return ; } const network = networks[account.symbol]; diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/ConnectPopupModal.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/ConnectPopupModal.tsx new file mode 100644 index 00000000000..edcb332b832 --- /dev/null +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/ConnectPopupModal.tsx @@ -0,0 +1,37 @@ +import { H2, Paragraph, NewModal } from '@trezor/components'; +import { spacings } from '@trezor/theme'; + +import { Translation } from 'src/components/suite'; + +interface ConnectPopupModalProps { + method: string; + onConfirm: () => void; + onCancel: () => void; +} + +export const ConnectPopupModal = ({ method, onConfirm, onCancel }: ConnectPopupModalProps) => { + return ( + + + + + + + + + } + heading={} + > +

{method}

+ + A 3rd party application is trying to connect to your device. Do you want to allow + this action? + +
+ ); +}; diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UserContextModal.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UserContextModal.tsx index 7295accb954..61dbaf771f5 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UserContextModal.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UserContextModal.tsx @@ -42,6 +42,7 @@ import { ConfirmUnverifiedAddressModal, ConfirmUnverifiedXpubModal, ConfirmUnverifiedProceedModal, + ConnectPopupModal, } from 'src/components/suite/modals'; import type { AcquiredDevice } from 'src/types/suite'; @@ -206,6 +207,14 @@ export const UserContextModal = ({ return ; case 'passphrase-mismatch-warning': return ; + case 'connect-popup': + return ( + + ); default: return null; } diff --git a/packages/suite/src/components/suite/modals/index.tsx b/packages/suite/src/components/suite/modals/index.tsx index ec560a00949..e4cfc440411 100644 --- a/packages/suite/src/components/suite/modals/index.tsx +++ b/packages/suite/src/components/suite/modals/index.tsx @@ -48,3 +48,4 @@ export { ClaimModal } from './ReduxModal/UserContextModal/ClaimModal/ClaimModal' export { MultiShareBackupModal } from './ReduxModal/UserContextModal/MultiShareBackupModal/MultiShareBackupModal'; export { CopyAddressModal } from './ReduxModal/CopyAddressModal'; export { UnhideTokenModal } from './ReduxModal/UnhideTokenModal'; +export { ConnectPopupModal } from './ReduxModal/UserContextModal/ConnectPopupModal'; diff --git a/suite-common/connect-init/src/connectInitThunks.ts b/suite-common/connect-init/src/connectInitThunks.ts index 1c4d1d32f54..2f9df4e5b8d 100644 --- a/suite-common/connect-init/src/connectInitThunks.ts +++ b/suite-common/connect-init/src/connectInitThunks.ts @@ -7,12 +7,12 @@ import TrezorConnect, { TRANSPORT_EVENT, UI_EVENT, } from '@trezor/connect'; -import { getSynchronize } from '@trezor/utils'; +import { createDeferred, getSynchronize } from '@trezor/utils'; import { deviceConnectThunks, selectDevice } from '@suite-common/wallet-core'; import { resolveStaticPath } from '@suite-common/suite-utils'; import { isDesktop, isNative } from '@trezor/env-utils'; import { desktopApi } from '@trezor/suite-desktop-api'; -import { serializeError, TypedError } from '@trezor/connect/src/constants/errors'; +import { serializeError } from '@trezor/connect/src/constants/errors'; import { cardanoConnectPatch } from './cardanoConnectPatch'; @@ -196,7 +196,15 @@ export const connectPopupCallThunk = createThunk( throw ERRORS.TypedError('Device_NotFound'); } - // TODO: go to some popup route + const confirmation = createDeferred(); + dispatch( + extra.actions.openModal({ + type: 'connect-popup', + onConfirm: () => confirmation.resolve(), + method, + }), + ); + await confirmation.promise; // @ts-expect-error: method is dynamic const response = await TrezorConnect[method]({ @@ -208,6 +216,8 @@ export const connectPopupCallThunk = createThunk( ...payload, }); + dispatch(extra.actions.onModalCancel()); + desktopApi.connectPopupResponse({ ...response, id, diff --git a/suite-common/suite-types/src/modal.ts b/suite-common/suite-types/src/modal.ts index 5e0ff9e328f..b99acab1cb2 100644 --- a/suite-common/suite-types/src/modal.ts +++ b/suite-common/suite-types/src/modal.ts @@ -183,4 +183,9 @@ export type UserContextPayload = } | { type: 'passphrase-mismatch-warning'; + } + | { + type: 'connect-popup'; + onConfirm: () => void; + method: string; }; From b37a896bc415979109491e7a1046a76e034f1bc5 Mon Sep 17 00:00:00 2001 From: Tomas Martykan Date: Thu, 14 Nov 2024 11:28:16 +0100 Subject: [PATCH 07/11] feat(suite): show descriptive method title in connect popup --- packages/connect/src/constants/errors.ts | 3 +++ suite-common/connect-init/src/connectInitThunks.ts | 14 +++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/connect/src/constants/errors.ts b/packages/connect/src/constants/errors.ts index 95ecf1fc038..a263a439e62 100644 --- a/packages/connect/src/constants/errors.ts +++ b/packages/connect/src/constants/errors.ts @@ -78,6 +78,9 @@ export const serializeError = (payload: any) => { if (payload && payload.error instanceof Error) { return { error: payload.error.message, code: payload.error.code }; } + if (payload instanceof TrezorError) { + return { error: payload.message, code: payload.code }; + } return payload; }; diff --git a/suite-common/connect-init/src/connectInitThunks.ts b/suite-common/connect-init/src/connectInitThunks.ts index 2f9df4e5b8d..533157b40ff 100644 --- a/suite-common/connect-init/src/connectInitThunks.ts +++ b/suite-common/connect-init/src/connectInitThunks.ts @@ -196,15 +196,26 @@ export const connectPopupCallThunk = createThunk( throw ERRORS.TypedError('Device_NotFound'); } + // @ts-expect-error: method is dynamic + const methodInfo = await TrezorConnect[method]({ + ...payload, + __info: true, + }); + if (!methodInfo.success) { + throw methodInfo; + } + const confirmation = createDeferred(); + dispatch(extra.actions.lockDevice(true)); dispatch( extra.actions.openModal({ type: 'connect-popup', onConfirm: () => confirmation.resolve(), - method, + method: methodInfo.payload.info, }), ); await confirmation.promise; + dispatch(extra.actions.lockDevice(false)); // @ts-expect-error: method is dynamic const response = await TrezorConnect[method]({ @@ -223,6 +234,7 @@ export const connectPopupCallThunk = createThunk( id, }); } catch (error) { + console.error('connectPopupCallThunk', error); desktopApi.connectPopupResponse({ success: false, payload: serializeError(error), From 344ace2256ca3eae51e2777530d34e095e1aaefd Mon Sep 17 00:00:00 2001 From: Tomas Martykan Date: Thu, 14 Nov 2024 14:50:41 +0100 Subject: [PATCH 08/11] feat(suite): connect popup display connecting process and web origin --- packages/suite-desktop-api/src/messages.ts | 2 + .../suite-desktop-core/src/libs/connect-ws.ts | 18 ++++- .../src/libs/find-process-from-port.ts | 69 +++++++++++++++++++ .../UserContextModal/ConnectPopupModal.tsx | 22 +++++- .../UserContextModal/UserContextModal.tsx | 4 +- .../connect-init/src/connectInitThunks.ts | 7 ++ suite-common/suite-types/src/modal.ts | 3 + 7 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 packages/suite-desktop-core/src/libs/find-process-from-port.ts diff --git a/packages/suite-desktop-api/src/messages.ts b/packages/suite-desktop-api/src/messages.ts index 350c187b45c..9b727689126 100644 --- a/packages/suite-desktop-api/src/messages.ts +++ b/packages/suite-desktop-api/src/messages.ts @@ -131,6 +131,8 @@ export type ConnectPopupCall = { id: number; method: string; payload: any; + processName?: string; + origin?: string; }; export type ConnectPopupResponse = { diff --git a/packages/suite-desktop-core/src/libs/connect-ws.ts b/packages/suite-desktop-core/src/libs/connect-ws.ts index c592f6a9951..b67a3b3d5e5 100644 --- a/packages/suite-desktop-core/src/libs/connect-ws.ts +++ b/packages/suite-desktop-core/src/libs/connect-ws.ts @@ -6,6 +6,7 @@ import { createDeferred, Deferred } from '@trezor/utils'; import { createHttpReceiver } from './http-receiver'; import { Dependencies } from '../modules'; +import { findProcessFromIncomingPort } from './find-process-from-port'; const LOG_PREFIX = 'connect-ws'; @@ -30,7 +31,20 @@ export const exposeConnectWs = ({ logger.info(LOG_PREFIX, 'Websocket server is listening'); }); - wss.on('connection', ws => { + wss.on('connection', async (ws, req) => { + const ip = req.socket.remoteAddress; + const port = req.socket.remotePort; + if ((ip !== '127.0.0.1' && ip !== '::1') || !port) { + logger.error(LOG_PREFIX, `invalid connection attempt from ${ip}:${port}`); + ws.close(); + + return; + } + logger.info(LOG_PREFIX, `new connection from ${ip}:${port}`); + const processOnPort = await findProcessFromIncomingPort(port); + const { origin } = req.headers; + logger.info(LOG_PREFIX, `origin: ${origin}`); + ws.on('error', err => { logger.error(LOG_PREFIX, err.message); }); @@ -85,6 +99,8 @@ export const exposeConnectWs = ({ id: message.id, method, payload: rest, + origin, + processName: processOnPort?.name, }); // wait for response diff --git a/packages/suite-desktop-core/src/libs/find-process-from-port.ts b/packages/suite-desktop-core/src/libs/find-process-from-port.ts new file mode 100644 index 00000000000..0f3f179592a --- /dev/null +++ b/packages/suite-desktop-core/src/libs/find-process-from-port.ts @@ -0,0 +1,69 @@ +import { spawn } from 'child_process'; + +export function spawnAndCollectStdout(command: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, { shell: true }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', data => { + stdout += data.toString(); + }); + child.stderr.on('data', data => { + stderr += data.toString(); + }); + child.on('close', code => { + if (code !== 0) { + reject(new Error(`Command failed with code ${code}: ${stderr}`)); + } else { + resolve(stdout); + } + }); + }); +} + +export async function findProcessFromIncomingPort(port: number) { + switch (process.platform) { + case 'darwin': + case 'linux': { + const command = `lsof -iTCP:${port} -sTCP:ESTABLISHED -n -P +c0`; + const stdout = await spawnAndCollectStdout(command); + const lines = stdout.split('\n'); + const process = lines.find(line => line.includes(`:${port}->`)); + if (process) { + const name = process.split(/\s+/)[0].replace(/\\x\d{2}/g, ' '); + const pid = process.split(/\s+/)[1]; + const user = process.split(/\s+/)[2]; + + return { name, pid, user }; + } + + return undefined; + } + case 'win32': { + const command = `netstat -ano | findstr :${port} | findstr ESTABLISHED`; + const stdout = await spawnAndCollectStdout(command); + const lines = stdout.split('\n'); + const record = lines + .map(line => { + const parts = line.split(/\s+/); + const pid = parts[parts.length - 1]; + const local = parts[2]; + + return { pid, local }; + }) + .find(({ local }) => local.endsWith(`:${port}`)); + if (record) { + const processInfo = await spawnAndCollectStdout( + `tasklist /FI "PID eq ${record.pid}" | findstr ${record.pid}`, + ); + const parts = processInfo.split(/\s+/); + const name = parts[0]; + const user = parts[2]; + + return { name, pid: record.pid, user }; + } + + return undefined; + } + } +} diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/ConnectPopupModal.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/ConnectPopupModal.tsx index edcb332b832..0bed22cf22a 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/ConnectPopupModal.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/ConnectPopupModal.tsx @@ -5,11 +5,19 @@ import { Translation } from 'src/components/suite'; interface ConnectPopupModalProps { method: string; + processName?: string; + origin?: string; onConfirm: () => void; onCancel: () => void; } -export const ConnectPopupModal = ({ method, onConfirm, onCancel }: ConnectPopupModalProps) => { +export const ConnectPopupModal = ({ + method, + processName, + origin, + onConfirm, + onCancel, +}: ConnectPopupModalProps) => { return ( } >

{method}

+ + {processName && ( + + Process: {processName} + + )} + {origin && ( + + Web Origin: {origin} + + )} + A 3rd party application is trying to connect to your device. Do you want to allow this action? diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UserContextModal.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UserContextModal.tsx index 61dbaf771f5..215e70cb0a6 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UserContextModal.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UserContextModal.tsx @@ -210,9 +210,11 @@ export const UserContextModal = ({ case 'connect-popup': return ( ); default: diff --git a/suite-common/connect-init/src/connectInitThunks.ts b/suite-common/connect-init/src/connectInitThunks.ts index 533157b40ff..d0c9ec40c36 100644 --- a/suite-common/connect-init/src/connectInitThunks.ts +++ b/suite-common/connect-init/src/connectInitThunks.ts @@ -177,10 +177,14 @@ export const connectPopupCallThunk = createThunk( id, method, payload, + processName, + origin, }: { id: number; method: string; payload: any; + processName?: string; + origin?: string; }, { dispatch, getState, extra }, ) => { @@ -210,8 +214,11 @@ export const connectPopupCallThunk = createThunk( dispatch( extra.actions.openModal({ type: 'connect-popup', + onCancel: () => confirmation.reject(ERRORS.TypedError('Method_Cancel')), onConfirm: () => confirmation.resolve(), method: methodInfo.payload.info, + processName, + origin, }), ); await confirmation.promise; diff --git a/suite-common/suite-types/src/modal.ts b/suite-common/suite-types/src/modal.ts index b99acab1cb2..17a1a6bd1d8 100644 --- a/suite-common/suite-types/src/modal.ts +++ b/suite-common/suite-types/src/modal.ts @@ -187,5 +187,8 @@ export type UserContextPayload = | { type: 'connect-popup'; onConfirm: () => void; + onCancel: () => void; method: string; + processName?: string; + origin?: string; }; From b1125c6be9171622866293bfb6289f5ad9585068 Mon Sep 17 00:00:00 2001 From: Tomas Martykan Date: Mon, 18 Nov 2024 13:12:17 +0100 Subject: [PATCH 09/11] chore(suite): interrupt discovery on connect popup --- .../src/impl/core-in-suite-desktop.ts | 3 +-- .../middlewares/wallet/discoveryMiddleware.ts | 22 ++++++++++++++----- .../connect-init/src/connectInitThunks.ts | 2 -- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/connect-web/src/impl/core-in-suite-desktop.ts b/packages/connect-web/src/impl/core-in-suite-desktop.ts index c677eacf9e3..8592c862bfd 100644 --- a/packages/connect-web/src/impl/core-in-suite-desktop.ts +++ b/packages/connect-web/src/impl/core-in-suite-desktop.ts @@ -23,8 +23,7 @@ import { createDeferred, createDeferredManager, DeferredManager } from '@trezor/ import { parseConnectSettings } from '../connectSettings'; /** - * Base class for CoreInPopup methods for TrezorConnect factory. - * This implementation is directly used here in connect-web, but it is also extended in connect-webextension. + * CoreInSuiteDesktop implementation for TrezorConnect factory. */ export class CoreInSuiteDesktop implements ConnectFactoryDependencies { public eventEmitter = new EventEmitter(); diff --git a/packages/suite/src/middlewares/wallet/discoveryMiddleware.ts b/packages/suite/src/middlewares/wallet/discoveryMiddleware.ts index 5058b730fa0..0d27ec73480 100644 --- a/packages/suite/src/middlewares/wallet/discoveryMiddleware.ts +++ b/packages/suite/src/middlewares/wallet/discoveryMiddleware.ts @@ -15,6 +15,7 @@ import { UI } from '@trezor/connect'; import { isDeviceAcquired } from '@suite-common/suite-utils'; import { DiscoveryStatus } from '@suite-common/wallet-constants'; import { createMiddlewareWithExtraDeps } from '@suite-common/redux-utils'; +import { connectPopupCallThunk } from '@suite-common/connect-init'; import { SUITE, ROUTER, MODAL } from 'src/actions/suite/constants'; import * as walletSettingsActions from 'src/actions/settings/walletSettingsActions'; @@ -46,12 +47,20 @@ export const prepareDiscoveryMiddleware = createMiddlewareWithExtraDeps( } // consider if discovery should be interrupted - let interruptionIntent = action.type === deviceActions.selectDevice.type; - if (action.type === ROUTER.LOCATION_CHANGE) { - interruptionIntent = - getApp(action.payload.url) !== 'wallet' && - getApp(action.payload.url) !== 'dashboard' && - getApp(action.payload.url) !== 'settings'; + let interruptionIntent = false; + if (action.type === deviceActions.selectDevice.type) { + interruptionIntent = true; + } + if ( + action.type === ROUTER.LOCATION_CHANGE && + getApp(action.payload.url) !== 'wallet' && + getApp(action.payload.url) !== 'dashboard' && + getApp(action.payload.url) !== 'settings' + ) { + interruptionIntent = true; + } + if (action.type === MODAL.OPEN_USER_CONTEXT && action.payload.type === 'connect-popup') { + interruptionIntent = true; } // discovery interruption ends after DISCOVERY.STOP action @@ -144,6 +153,7 @@ export const prepareDiscoveryMiddleware = createMiddlewareWithExtraDeps( if ( becomesConnected || action.type === SUITE.APP_CHANGED || + connectPopupCallThunk.fulfilled.match(action) || deviceActions.selectDevice.match(action) || authorizeDeviceThunk.fulfilled.match(action) || walletSettingsActions.changeNetworks.match(action) || diff --git a/suite-common/connect-init/src/connectInitThunks.ts b/suite-common/connect-init/src/connectInitThunks.ts index d0c9ec40c36..b5af94e1c4a 100644 --- a/suite-common/connect-init/src/connectInitThunks.ts +++ b/suite-common/connect-init/src/connectInitThunks.ts @@ -193,8 +193,6 @@ export const connectPopupCallThunk = createThunk( if (!device) { console.error('Device not found'); - // Need to select device first - dispatch(extra.thunks.openSwitchDeviceDialog()); // TODO: wait for device selection and continue throw ERRORS.TypedError('Device_NotFound'); From 2e1fa633de7002f1e9398ff0921faea84e725466 Mon Sep 17 00:00:00 2001 From: Tomas Martykan Date: Mon, 25 Nov 2024 13:55:43 +0100 Subject: [PATCH 10/11] fix(suite-desktop-core): init connect popup only if really enabled --- packages/suite-desktop-api/src/api.ts | 2 ++ packages/suite-desktop-api/src/factory.ts | 1 + packages/suite-desktop-core/src/app.ts | 3 ++- .../suite-desktop-core/src/modules/http-receiver.ts | 8 +++++++- suite-common/connect-init/src/connectInitThunks.ts | 13 ++++++------- 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/suite-desktop-api/src/api.ts b/packages/suite-desktop-api/src/api.ts index a00c278cccf..4838d294fec 100644 --- a/packages/suite-desktop-api/src/api.ts +++ b/packages/suite-desktop-api/src/api.ts @@ -104,6 +104,7 @@ export interface InvokeChannels { 'app/auto-start/is-enabled': () => InvokeResult; 'tray/change-settings': (payload: TraySettings) => InvokeResult; 'tray/get-settings': () => InvokeResult; + 'connect-popup/enabled': () => boolean; 'connect-popup/ready': () => void; 'connect-popup/response': (response: ConnectPopupResponse) => void; } @@ -169,6 +170,7 @@ export interface DesktopApi { changeTraySettings: DesktopApiInvoke<'tray/change-settings'>; getTraySettings: DesktopApiInvoke<'tray/get-settings'>; // Connect popup + connectPopupEnabled: DesktopApiInvoke<'connect-popup/enabled'>; connectPopupReady: DesktopApiInvoke<'connect-popup/ready'>; connectPopupResponse: DesktopApiInvoke<'connect-popup/response'>; } diff --git a/packages/suite-desktop-api/src/factory.ts b/packages/suite-desktop-api/src/factory.ts index 82b2925e484..c3f53e49602 100644 --- a/packages/suite-desktop-api/src/factory.ts +++ b/packages/suite-desktop-api/src/factory.ts @@ -179,6 +179,7 @@ export const factory = >( getTraySettings: () => ipcRenderer.invoke('tray/get-settings'), // Connect popup + connectPopupEnabled: () => ipcRenderer.invoke('connect-popup/enabled'), connectPopupReady: () => ipcRenderer.invoke('connect-popup/ready'), connectPopupResponse: response => ipcRenderer.invoke('connect-popup/response', response), }; diff --git a/packages/suite-desktop-core/src/app.ts b/packages/suite-desktop-core/src/app.ts index 019b8d5fb40..ede3f18a8ae 100644 --- a/packages/suite-desktop-core/src/app.ts +++ b/packages/suite-desktop-core/src/app.ts @@ -268,7 +268,8 @@ const init = async () => { const mainWindow = mainWindowProxy.getInstance(); const windowExists = mainWindow && !mainWindow.isDestroyed() && mainWindow.isClosable() && !app.isHidden(); - const autoStartCurrentlyEnabled = isAutoStartEnabled(); + const autoStartCurrentlyEnabled = + isAutoStartEnabled() || app.commandLine.hasSwitch('bridge-test'); logger.info('main', `Before quit, window exists: ${windowExists}`); if ( !stoppingDaemon && diff --git a/packages/suite-desktop-core/src/modules/http-receiver.ts b/packages/suite-desktop-core/src/modules/http-receiver.ts index 53206a24930..e3310fb7690 100644 --- a/packages/suite-desktop-core/src/modules/http-receiver.ts +++ b/packages/suite-desktop-core/src/modules/http-receiver.ts @@ -54,7 +54,13 @@ export const initBackground: ModuleInitBackground = ({ mainWindowProxy, mainThre return receiver.getRouteAddress(pathname); }); - if (app.commandLine.hasSwitch('expose-connect-ws') || isDevEnv) { + const connectPopupEnabled = app.commandLine.hasSwitch('expose-connect-ws') || isDevEnv; + ipcMain.handle('connect-popup/enabled', ipcEvent => { + validateIpcMessage(ipcEvent); + + return connectPopupEnabled; + }); + if (connectPopupEnabled) { exposeConnectWs({ mainThreadEmitter, httpReceiver: receiver, mainWindowProxy }); } diff --git a/suite-common/connect-init/src/connectInitThunks.ts b/suite-common/connect-init/src/connectInitThunks.ts index b5af94e1c4a..674ab9d98fa 100644 --- a/suite-common/connect-init/src/connectInitThunks.ts +++ b/suite-common/connect-init/src/connectInitThunks.ts @@ -251,13 +251,12 @@ export const connectPopupCallThunk = createThunk( export const connectPopupInitThunk = createThunk( `${CONNECT_INIT_MODULE}/initPopupThunk`, - (_, { dispatch }) => { - if (!desktopApi.available) { - return; + async (_, { dispatch }) => { + if (desktopApi.available && (await desktopApi.connectPopupEnabled())) { + desktopApi.on('connect-popup/call', params => { + dispatch(connectPopupCallThunk(params)); + }); + desktopApi.connectPopupReady(); } - desktopApi.on('connect-popup/call', params => { - dispatch(connectPopupCallThunk(params)); - }); - desktopApi.connectPopupReady(); }, ); From 9bcddac14e0eff2448f648cd045d4e5a51d73b6d Mon Sep 17 00:00:00 2001 From: Tomas Martykan Date: Mon, 25 Nov 2024 14:08:45 +0100 Subject: [PATCH 11/11] fix(suite-desktop-core): don't fail hard if http-receiver port is occupied --- .../suite-desktop-core/src/modules/http-receiver.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/suite-desktop-core/src/modules/http-receiver.ts b/packages/suite-desktop-core/src/modules/http-receiver.ts index e3310fb7690..8297c25d858 100644 --- a/packages/suite-desktop-core/src/modules/http-receiver.ts +++ b/packages/suite-desktop-core/src/modules/http-receiver.ts @@ -65,9 +65,17 @@ export const initBackground: ModuleInitBackground = ({ mainWindowProxy, mainThre } logger.info(SERVICE_NAME, 'Starting server'); - await receiver.start(); - return receiver.getInfo(); + try { + await receiver.start(); + + return receiver.getInfo(); + } catch (error) { + // Don't fail hard if the server can't start + logger.error(SERVICE_NAME, 'Failed to start server: ' + error); + + return { url: null }; + } }; const onQuit = async () => {