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;
+}