diff --git a/web/packages/teleterm/src/mainProcess/ipcSerializer.test.ts b/web/packages/teleterm/src/mainProcess/ipcSerializer.test.ts new file mode 100644 index 0000000000000..5b3ab156b29c5 --- /dev/null +++ b/web/packages/teleterm/src/mainProcess/ipcSerializer.test.ts @@ -0,0 +1,68 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { TshdRpcError } from 'teleterm/services/tshd/cloneableClient'; + +import { deserializeError, serializeError } from './ipcSerializer'; + +test('serializes and deserializes regular error', () => { + const err = new Error('Boom'); + err.name = 'CustomError'; + + const serialized = serializeError(err); + expect(serialized instanceof Error).toBe(false); + expect(serialized.message).toBe('Boom'); + expect(serialized.name).toBe('CustomError'); + expect(serialized.stack).toBe(err.stack); + expect(serialized.cause).toBe(err.cause); + + const cloned = structuredClone(serialized); + const deserialized = deserializeError(cloned); + expect(deserialized instanceof Error).toBe(true); + expect(deserialized.message).toBe('Boom'); + expect(deserialized.name).toBe('CustomError'); + expect(deserialized.stack).toBe(err.stack); + expect(deserialized.cause).toBe(err.cause); +}); + +test('serializes and deserializes tshd error', () => { + const err: TshdRpcError = { + name: 'TshdRpcError', + message: 'Could not found', + toString: () => 'Could not found', + cause: '', + stack: '', + code: 'NOT_FOUND', + isResolvableWithRelogin: false, + }; + + const serialized = serializeError(err); + expect(serialized instanceof Error).toBe(false); + expect(serialized.message).toBe('Could not found'); + expect(serialized.name).toBe('TshdRpcError'); + expect(serialized['isResolvableWithRelogin']).toBe(false); + expect(serialized['code']).toBe('NOT_FOUND'); + + const cloned = structuredClone(serialized); + const deserialized = deserializeError(cloned); + expect(deserialized instanceof Error).toBe(true); + expect(deserialized.message).toBe('Could not found'); + expect(deserialized.name).toBe('TshdRpcError'); + expect(deserialized['isResolvableWithRelogin']).toBe(false); + expect(deserialized['code']).toBe('NOT_FOUND'); +}); diff --git a/web/packages/teleterm/src/mainProcess/ipcSerializer.ts b/web/packages/teleterm/src/mainProcess/ipcSerializer.ts index 42555704147c5..7011f6b5b47e8 100644 --- a/web/packages/teleterm/src/mainProcess/ipcSerializer.ts +++ b/web/packages/teleterm/src/mainProcess/ipcSerializer.ts @@ -21,23 +21,41 @@ export type SerializedError = { message: string; stack?: string; cause?: unknown; + toStringResult?: string; }; /** Serializes an Error into a plain object for transport through Electron IPC. */ export function serializeError(error: Error): SerializedError { + const { + name, + cause, + stack, + message, + // functions must be skipped, otherwise structuredClone will fail to clone the object + // eslint-disable-next-line unused-imports/no-unused-vars + toString, + ...enumerableFields + } = error; return { - name: error.name, - message: error.message, - cause: error.cause, - stack: error.stack, + name, + message, + cause, + stack, + // Calling the destructured function directly could result in the following error: + // Method Error.prototype.toString called on incompatible receiver undefined + toStringResult: error.toString?.(), + ...enumerableFields, }; } /** Deserializes a plain object back into an Error instance. */ export function deserializeError(serialized: SerializedError): Error { - const error = new Error(serialized.message); - error.name = serialized.name; - error.cause = serialized.cause; - error.stack = serialized.stack; + const { name, cause, stack, message, toStringResult, ...rest } = serialized; + const error = new Error(message); + error.name = name; + error.cause = cause; + error.stack = stack; + error.toString = () => toStringResult; + Object.assign(error, rest); return error; } diff --git a/web/packages/teleterm/src/mainProcess/mainProcess.ts b/web/packages/teleterm/src/mainProcess/mainProcess.ts index 7b9ef31a1501e..21cd4177e1e41 100644 --- a/web/packages/teleterm/src/mainProcess/mainProcess.ts +++ b/web/packages/teleterm/src/mainProcess/mainProcess.ts @@ -27,6 +27,7 @@ import { app, dialog, ipcMain, + IpcMainInvokeEvent, Menu, MenuItemConstructorOptions, nativeTheme, @@ -415,17 +416,17 @@ export default class MainProcess { event.returnValue = nativeTheme.shouldUseDarkColors; }); - ipcMain.handle('main-process-get-resolved-child-process-addresses', () => { + ipcHandle('main-process-get-resolved-child-process-addresses', () => { return this.resolvedChildProcessAddresses; }); - ipcMain.handle('main-process-show-file-save-dialog', (_, filePath) => + ipcHandle('main-process-show-file-save-dialog', (_, filePath) => dialog.showSaveDialog({ defaultPath: path.basename(filePath), }) ); - ipcMain.handle( + ipcHandle( MainProcessIpc.SaveTextToFile, async ( _, @@ -464,7 +465,7 @@ export default class MainProcess { } ); - ipcMain.handle( + ipcHandle( MainProcessIpc.ForceFocusWindow, async ( _, @@ -485,7 +486,7 @@ export default class MainProcess { // Used in the `tsh install` command on macOS to make the bundled tsh available in PATH. // Returns true if tsh got successfully installed, false if the user closed the osascript // prompt. Throws an error when osascript fails. - ipcMain.handle('main-process-symlink-tsh-macos', async () => { + ipcHandle('main-process-symlink-tsh-macos', async () => { const source = this.settings.tshd.binaryPath; const target = '/usr/local/bin/tsh'; const prompt = @@ -507,7 +508,7 @@ export default class MainProcess { } }); - ipcMain.handle('main-process-remove-tsh-symlink-macos', async () => { + ipcHandle('main-process-remove-tsh-symlink-macos', async () => { const target = '/usr/local/bin/tsh'; const prompt = 'Teleport Connect wants to remove a symlink for tsh from /usr/local/bin.'; @@ -528,21 +529,21 @@ export default class MainProcess { } }); - ipcMain.handle('main-process-open-config-file', async () => { + ipcHandle('main-process-open-config-file', async () => { const path = this.configFileStorage.getFilePath(); await shell.openPath(path); return path; }); - ipcMain.handle(MainProcessIpc.DownloadConnectMyComputerAgent, () => + ipcHandle(MainProcessIpc.DownloadConnectMyComputerAgent, () => this.downloadAgentShared() ); - ipcMain.handle(MainProcessIpc.VerifyConnectMyComputerAgent, async () => { + ipcHandle(MainProcessIpc.VerifyConnectMyComputerAgent, async () => { await verifyAgent(this.settings.agentBinaryPath); }); - ipcMain.handle( + ipcHandle( 'main-process-connect-my-computer-create-agent-config-file', (_, args: CreateAgentConfigFileArgs) => createAgentConfigFile(this.settings, { @@ -553,7 +554,7 @@ export default class MainProcess { }) ); - ipcMain.handle( + ipcHandle( 'main-process-connect-my-computer-is-agent-config-file-created', async ( _, @@ -563,7 +564,7 @@ export default class MainProcess { ) => isAgentConfigFileCreated(this.settings, args.rootClusterUri) ); - ipcMain.handle( + ipcHandle( 'main-process-connect-my-computer-kill-agent', async ( _, @@ -575,7 +576,7 @@ export default class MainProcess { } ); - ipcMain.handle( + ipcHandle( 'main-process-connect-my-computer-remove-agent-directory', ( _, @@ -585,11 +586,11 @@ export default class MainProcess { ) => removeAgentDirectory(this.settings, args.rootClusterUri) ); - ipcMain.handle(MainProcessIpc.TryRemoveConnectMyComputerAgentBinary, () => + ipcHandle(MainProcessIpc.TryRemoveConnectMyComputerAgentBinary, () => this.agentRunner.tryRemoveAgentBinary() ); - ipcMain.handle( + ipcHandle( 'main-process-connect-my-computer-run-agent', async ( _, @@ -625,7 +626,7 @@ export default class MainProcess { } ); - ipcMain.handle( + ipcHandle( 'main-process-open-agent-logs-directory', async ( _, @@ -644,7 +645,7 @@ export default class MainProcess { } ); - ipcMain.handle( + ipcHandle( MainProcessIpc.SelectDirectoryForDesktopSession, async (_, args: { desktopUri: string; login: string }) => { const value = await dialog.showOpenDialog({ @@ -673,11 +674,11 @@ export default class MainProcess { event.returnValue = this.appUpdater.supportsUpdates(); }); - ipcMain.handle(MainProcessIpc.CheckForAppUpdates, () => + ipcHandle(MainProcessIpc.CheckForAppUpdates, () => this.appUpdater.checkForUpdates() ); - ipcMain.handle( + ipcHandle( MainProcessIpc.ChangeAppUpdatesManagingCluster, ( event, @@ -687,31 +688,31 @@ export default class MainProcess { ) => this.appUpdater.changeManagingCluster(args.clusterUri) ); - ipcMain.handle(MainProcessIpc.DownloadAppUpdate, () => + ipcHandle(MainProcessIpc.DownloadAppUpdate, () => this.appUpdater.download() ); - ipcMain.handle(MainProcessIpc.CancelAppUpdateDownload, () => + ipcHandle(MainProcessIpc.CancelAppUpdateDownload, () => this.appUpdater.cancelDownload() ); - ipcMain.handle(MainProcessIpc.QuiteAndInstallAppUpdate, () => + ipcHandle(MainProcessIpc.QuiteAndInstallAppUpdate, () => this.appUpdater.quitAndInstall() ); - ipcMain.handle(MainProcessIpc.AddCluster, (ev, proxyAddress) => + ipcHandle(MainProcessIpc.AddCluster, (ev, proxyAddress) => this.clusterLifecycleManager.addCluster(proxyAddress) ); - ipcMain.handle(MainProcessIpc.SyncRootClusters, () => + ipcHandle(MainProcessIpc.SyncRootClusters, () => this.clusterLifecycleManager.syncRootClustersAndStartProfileWatcher() ); - ipcMain.handle(MainProcessIpc.SyncCluster, (_, args) => + ipcHandle(MainProcessIpc.SyncCluster, (_, args) => this.clusterLifecycleManager.syncCluster(args.clusterUri) ); - ipcMain.handle(MainProcessIpc.Logout, async (_, args) => { + ipcHandle(MainProcessIpc.Logout, async (_, args) => { await this.clusterLifecycleManager.logoutAndRemoveCluster( args.clusterUri ); @@ -1010,3 +1011,23 @@ function makeAppUpdaterStorage(fs: FileStorage): AppUpdaterStorage { }, }; } + +/** + * Handles requests sent via `ipcInvoke`. + * The renderer must send requests using `ipcInvoke` (not `ipcRenderer.invoke`). + * + * Use this instead of `ipcMain.handle`. It ensures full error serialization + * and prevents Electron from adding the generic message "Error invoking remote method". + */ +function ipcHandle( + channel: string, + listener: (event: IpcMainInvokeEvent, ...args: any[]) => Promise | any +): void { + ipcMain.handle(channel, async (...args) => { + try { + return { result: await Promise.try(listener, ...args) }; + } catch (e) { + return { error: serializeError(e) }; + } + }); +} diff --git a/web/packages/teleterm/src/mainProcess/mainProcessClient.ts b/web/packages/teleterm/src/mainProcess/mainProcessClient.ts index 39922f62fd62a..44eb7726442ce 100644 --- a/web/packages/teleterm/src/mainProcess/mainProcessClient.ts +++ b/web/packages/teleterm/src/mainProcess/mainProcessClient.ts @@ -18,8 +18,10 @@ import { ipcRenderer } from 'electron'; +import Logger from 'teleterm/logger'; import type { Message, MessageAck } from 'teleterm/mainProcess/awaitableSender'; import { CreateAgentConfigFileArgs } from 'teleterm/mainProcess/createAgentConfigFile'; +import { deserializeError } from 'teleterm/mainProcess/ipcSerializer'; import { AppUpdateEvent } from 'teleterm/services/appUpdater'; import { createFileStorageClient } from 'teleterm/services/fileStorage'; import { RootClusterUri } from 'teleterm/ui/uri'; @@ -36,6 +38,8 @@ import { WindowsManagerIpc, } from './types'; +const logger = new Logger('MainProcessClient'); + export default function createMainProcessClient(): MainProcessClient { return { /* @@ -98,78 +102,68 @@ export default function createMainProcessClient(): MainProcessClient { // TODO(ravicious): Convert the rest of IPC channels to use enums defined in types.ts such as // MainProcessIpc rather than hardcoded strings. getResolvedChildProcessAddresses(): Promise { - return ipcRenderer.invoke( - 'main-process-get-resolved-child-process-addresses' - ); + return ipcInvoke('main-process-get-resolved-child-process-addresses'); }, showFileSaveDialog(filePath: string) { - return ipcRenderer.invoke('main-process-show-file-save-dialog', filePath); + return ipcInvoke('main-process-show-file-save-dialog', filePath); }, saveTextToFile(args) { - return ipcRenderer.invoke(MainProcessIpc.SaveTextToFile, args); + return ipcInvoke(MainProcessIpc.SaveTextToFile, args); }, openTerminalContextMenu, openTabContextMenu, configService: createConfigServiceClient(), fileStorage: createFileStorageClient(), forceFocusWindow(args) { - return ipcRenderer.invoke(MainProcessIpc.ForceFocusWindow, args); + return ipcInvoke(MainProcessIpc.ForceFocusWindow, args); }, symlinkTshMacOs() { - return ipcRenderer.invoke('main-process-symlink-tsh-macos'); + return ipcInvoke('main-process-symlink-tsh-macos'); }, removeTshSymlinkMacOs() { - return ipcRenderer.invoke('main-process-remove-tsh-symlink-macos'); + return ipcInvoke('main-process-remove-tsh-symlink-macos'); }, openConfigFile() { - return ipcRenderer.invoke('main-process-open-config-file'); + return ipcInvoke('main-process-open-config-file'); }, shouldUseDarkColors() { return ipcRenderer.sendSync('main-process-should-use-dark-colors'); }, downloadAgent() { - return ipcRenderer.invoke(MainProcessIpc.DownloadConnectMyComputerAgent); + return ipcInvoke(MainProcessIpc.DownloadConnectMyComputerAgent); }, verifyAgent() { - return ipcRenderer.invoke(MainProcessIpc.VerifyConnectMyComputerAgent); + return ipcInvoke(MainProcessIpc.VerifyConnectMyComputerAgent); }, createAgentConfigFile(args: CreateAgentConfigFileArgs) { - return ipcRenderer.invoke( + return ipcInvoke( 'main-process-connect-my-computer-create-agent-config-file', args ); }, isAgentConfigFileCreated(args: { rootClusterUri: RootClusterUri }) { - return ipcRenderer.invoke( + return ipcInvoke( 'main-process-connect-my-computer-is-agent-config-file-created', args ); }, removeAgentDirectory(args: { rootClusterUri: RootClusterUri }) { - return ipcRenderer.invoke( + return ipcInvoke( 'main-process-connect-my-computer-remove-agent-directory', args ); }, tryRemoveConnectMyComputerAgentBinary() { - return ipcRenderer.invoke( - MainProcessIpc.TryRemoveConnectMyComputerAgentBinary - ); + return ipcInvoke(MainProcessIpc.TryRemoveConnectMyComputerAgentBinary); }, openAgentLogsDirectory(args: { rootClusterUri: RootClusterUri }) { - return ipcRenderer.invoke('main-process-open-agent-logs-directory', args); + return ipcInvoke('main-process-open-agent-logs-directory', args); }, killAgent(args: { rootClusterUri: RootClusterUri }) { - return ipcRenderer.invoke( - 'main-process-connect-my-computer-kill-agent', - args - ); + return ipcInvoke('main-process-connect-my-computer-kill-agent', args); }, runAgent(args: { rootClusterUri: RootClusterUri }) { - return ipcRenderer.invoke( - 'main-process-connect-my-computer-run-agent', - args - ); + return ipcInvoke('main-process-connect-my-computer-run-agent', args); }, getAgentState(args: { rootClusterUri: RootClusterUri }) { return ipcRenderer.sendSync( @@ -190,33 +184,27 @@ export default function createMainProcessClient(): MainProcessClient { desktopUri: string; login: string; }) { - return ipcRenderer.invoke( - MainProcessIpc.SelectDirectoryForDesktopSession, - args - ); + return ipcInvoke(MainProcessIpc.SelectDirectoryForDesktopSession, args); }, supportsAppUpdates() { return ipcRenderer.sendSync(MainProcessIpc.SupportsAppUpdates); }, checkForAppUpdates() { - return ipcRenderer.invoke(MainProcessIpc.CheckForAppUpdates); + return ipcInvoke(MainProcessIpc.CheckForAppUpdates); }, downloadAppUpdate() { - return ipcRenderer.invoke(MainProcessIpc.DownloadAppUpdate); + return ipcInvoke(MainProcessIpc.DownloadAppUpdate); }, cancelAppUpdateDownload() { - return ipcRenderer.invoke(MainProcessIpc.CancelAppUpdateDownload); + return ipcInvoke(MainProcessIpc.CancelAppUpdateDownload); }, quitAndInstallAppUpdate() { - return ipcRenderer.invoke(MainProcessIpc.QuiteAndInstallAppUpdate); + return ipcInvoke(MainProcessIpc.QuiteAndInstallAppUpdate); }, changeAppUpdatesManagingCluster(clusterUri) { - return ipcRenderer.invoke( - MainProcessIpc.ChangeAppUpdatesManagingCluster, - { - clusterUri, - } - ); + return ipcInvoke(MainProcessIpc.ChangeAppUpdatesManagingCluster, { + clusterUri, + }); }, subscribeToAppUpdateEvents: listener => { const ipcListener = (_, updateEvent: AppUpdateEvent) => { @@ -261,16 +249,16 @@ export default function createMainProcessClient(): MainProcessClient { }; }, addCluster: async (proxyAddress: string) => { - return await ipcRenderer.invoke(MainProcessIpc.AddCluster, proxyAddress); + return await ipcInvoke(MainProcessIpc.AddCluster, proxyAddress); }, syncRootClusters: async () => { - return await ipcRenderer.invoke(MainProcessIpc.SyncRootClusters); + return await ipcInvoke(MainProcessIpc.SyncRootClusters); }, syncCluster: (clusterUri: RootClusterUri) => { - return ipcRenderer.invoke(MainProcessIpc.SyncCluster, { clusterUri }); + return ipcInvoke(MainProcessIpc.SyncCluster, { clusterUri }); }, logout: (clusterUri: RootClusterUri) => { - return ipcRenderer.invoke(MainProcessIpc.Logout, { clusterUri }); + return ipcInvoke(MainProcessIpc.Logout, { clusterUri }); }, registerClusterLifecycleHandler(listener): { cleanup: () => void; @@ -341,3 +329,19 @@ function startAwaitableSenderListener( }, }; } + +/** + * Resolves with the response from the main process. + * The main process must register the handler using `ipcHandle` (not `ipcMain.handle`). + * + * Use this instead of `ipcRenderer.invoke`. + */ +async function ipcInvoke(channel: string, ...args: any[]): Promise { + const { error, result } = await ipcRenderer.invoke(channel, ...args); + if (error) { + const deserialized = deserializeError(error); + logger.error(`Error invoking remote method ${channel}`, deserialized); + throw deserialized; + } + return result; +}