import uuid from 'uuid/v4'; import { serializeError } from 'serialize-error'; import type { IpcMain, IpcRenderer, WebContents, IpcMainEvent, IpcRendererEvent } from 'electron'; import 'object.entries/auto'; // Shim Object.entries. Required to use serializeError. type IpcEvent = IpcRendererEvent & IpcMainEvent; /** * For backwards compatibility, event is the (optional) LAST argument to a listener function. * This leads to the following verbose overload type for a listener function. */ export type Listener = | { (event?: IpcEvent): void } | { (arg1?: unknown, event?: IpcEvent): void } | { (arg1?: unknown, arg2?: unknown, event?: IpcEvent): void } | { (arg1?: unknown, arg2?: unknown, arg3?: unknown, event?: IpcEvent): void } | { ( arg1?: unknown, arg2?: unknown, arg3?: unknown, arg4?: unknown, event?: IpcEvent, ): void; } | { ( arg1?: unknown, arg2?: unknown, arg3?: unknown, arg4?: unknown, arg5?: unknown, event?: IpcEvent, ): void; }; export type Options = { maxTimeoutMs?: number }; // There's an `any` here it's the only way that the typescript compiler allows you to call listener(...dataArgs, event). // eslint-disable-next-line @typescript-eslint/no-explicit-any type WrappedListener = { (event: IpcEvent, replyChannel: string, ...dataArgs: any[]): void }; export default class PromiseIpcBase { private eventEmitter: IpcMain | IpcRenderer; private maxTimeoutMs: number; private routeListenerMap: Map; private listenerMap: Map; constructor(opts: { maxTimeoutMs?: number } | undefined, eventEmitter: IpcMain | IpcRenderer) { if (opts && opts.maxTimeoutMs) { this.maxTimeoutMs = opts.maxTimeoutMs; } // either ipcRenderer or ipcMain this.eventEmitter = eventEmitter; this.routeListenerMap = new Map(); this.listenerMap = new Map(); } public send( route: string, sender: WebContents | IpcRenderer, ...dataArgs: unknown[] ): Promise { return new Promise((resolve, reject) => { const replyChannel = `${route}#${uuid()}`; let timeout: NodeJS.Timeout; let didTimeOut = false; // ipcRenderer will send a message back to replyChannel when it finishes calculating this.eventEmitter.once( replyChannel, (event: IpcEvent, status: string, returnData: unknown) => { clearTimeout(timeout); if (didTimeOut) { return null; } switch (status) { case 'success': return resolve(returnData); case 'failure': return reject(returnData); default: return reject(new Error(`Unexpected IPC call status "${status}" in ${route}`)); } }, ); sender.send(route, replyChannel, ...dataArgs); if (this.maxTimeoutMs) { timeout = setTimeout(() => { didTimeOut = true; reject(new Error(`${route} timed out.`)); }, this.maxTimeoutMs); } }); } public on(route: string, listener: Listener): PromiseIpcBase { const prevListener = this.routeListenerMap.get(route); // If listener has already been added for this route, don't add it again. if (prevListener === listener) { return this; } // Only one listener may be active for a given route. // If two are active promises it won't work correctly - that's a race condition. if (this.routeListenerMap.has(route)) { this.off(route, prevListener); } // This function _wraps_ the listener argument. We maintain a map of // listener -> wrapped listener in order to implement #off(). const wrappedListener: WrappedListener = (event, replyChannel, ...dataArgs): void => { // Chaining off of Promise.resolve() means that listener can return a promise, or return // synchronously -- it can even throw. The end result will still be handled promise-like. Promise.resolve() .then(() => listener(...dataArgs, event)) .then((results) => { event.sender.send(replyChannel, 'success', results); }) .catch((e) => { event.sender.send(replyChannel, 'failure', serializeError(e)); }); }; this.routeListenerMap.set(route, listener); this.listenerMap.set(listener, wrappedListener); this.eventEmitter.on(route, wrappedListener); return this; } public off(route: string, listener?: Listener): void { const registeredListener = this.routeListenerMap.get(route); if (listener && listener !== registeredListener) { return; // trying to remove the wrong listener, so do nothing. } const wrappedListener = this.listenerMap.get(registeredListener); this.eventEmitter.removeListener(route, wrappedListener); this.listenerMap.delete(registeredListener); this.routeListenerMap.delete(route); } public removeListener(route: string, listener?: Listener): void { this.off(route, listener); } }