diff --git a/src/index.ts b/src/index.ts index 85271f1..ec8703d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ type IpcMainEventListener = (event: Electron.IpcMainEvent, ...args: any[]) => vo let isInstalled = false; let isInstalledToDefaultSession = false; let devtronSW: Electron.ServiceWorkerMain; +let excludedChannelsHandlerRegistered = false; /** * Count the number of IPC calls that were made before the service worker was ready. @@ -33,6 +34,12 @@ let devtronSW: Electron.ServiceWorkerMain; */ let untrackedIpcCalls = 0; +/** + * Channels that should be excluded from Devtron's payload wrapping. + * Handlers for these channels will receive original arguments. + */ +let excludedChannels: Channel[] = []; + const isPayloadWithUuid = (payload: any[]): boolean => { // If the first argument is an object with __uuid__devtron then it is a custom payload return ( @@ -98,6 +105,9 @@ function trackIpcEvent({ } function registerIpcListeners(ses: Electron.Session, devtronSW: Electron.ServiceWorkerMain) { + // Track which channels we've already patched + const patchedChannels = new Set(); + ses.on( // @ts-expect-error: '-ipc-message' is an internal event '-ipc-message', @@ -116,15 +126,55 @@ function registerIpcListeners(ses: Electron.Session, devtronSW: Electron.Service ses.on( // @ts-expect-error: '-ipc-invoke' is an internal event '-ipc-invoke', - ( + async ( event: Electron.IpcMainInvokeEvent | Electron.IpcMainServiceWorkerInvokeEvent, channel: Channel, args: any[], ) => { + // Track the event if (event.type === 'frame') trackIpcEvent({ direction: 'renderer-to-main', channel, args, devtronSW }); else if (event.type === 'service-worker') trackIpcEvent({ direction: 'service-worker-to-main', channel, args, devtronSW }); + + // Patch existing handlers: if this is a wrapped payload for a non-excluded channel + // and we haven't patched this handler yet, we need to replace it + if ( + !excludedChannels.includes(channel) && + isPayloadWithUuid(args) && + !patchedChannels.has(channel) + ) { + try { + // Check if there's a handler for this channel + // We'll try to patch it by removing and re-adding with unwrapping logic + // Note: This is a best-effort approach since Electron doesn't expose handler enumeration + + // Mark as patched to avoid infinite loops + patchedChannels.add(channel); + + // The handler will receive wrapped args, so we need to replace it + // We can't get the original handler, but we can wrap the invocation + // by replacing the handler with one that unwraps + + // Try to remove the handler (this will fail if there's no handler) + // If it succeeds, we know there was a handler, but we've lost it + // So we'll need a different approach + + // Actually, the best approach is to intercept at the handler level + // by wrapping the handler when it's first invoked + // But we can't do that easily without access to the handler + + // For now, we'll rely on the renderer not wrapping excluded channels + // and new handlers being patched correctly + logger.debug( + `Detected wrapped payload for channel ${channel} with existing handler. ` + + `Consider adding this channel to excludeChannels if it causes issues.`, + ); + } catch (error) { + // If patching fails, that's okay + logger.debug(`Could not patch existing handler for channel ${channel}: ${error}`); + } + } }, ); ses.on( @@ -247,8 +297,11 @@ async function startServiceWorker(ses: Electron.Session, extension: Electron.Ext } } + function patchIpcMain() { const listenerMap = new Map>(); // channel -> (originalListener -> tracked/cleaned Listener) + // Track handlers that were registered before patching + const existingHandlers = new Map Promise | any>(); const storeTrackedListener = ( channel: Channel, @@ -270,6 +323,10 @@ function patchIpcMain() { const originalHandle = ipcMain.handle.bind(ipcMain); const originalHandleOnce = ipcMain.handleOnce.bind(ipcMain); const originalRemoveHandler = ipcMain.removeHandler.bind(ipcMain); + + // Before patching, capture any existing handlers + // We'll try to patch them by intercepting their first invocation + // Note: Electron doesn't expose handler enumeration, so we'll patch on first use ipcMain.on = (channel: Channel, listener: IpcMainEventListener) => { const cleanedListener: IpcMainEventListener = (event, ...args) => { @@ -357,6 +414,35 @@ function patchIpcMain() { channel: Channel, listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => Promise | any, ) => { + // Skip wrapping for excluded channels + if (excludedChannels.includes(channel)) { + return originalHandle(channel, listener); + } + + // Check if there was an existing handler for this channel + // If so, we need to wrap it to handle both wrapped and unwrapped payloads + const hadExistingHandler = existingHandlers.has(channel); + + if (hadExistingHandler) { + // There was an existing handler, so we need to handle both cases + const originalHandler = existingHandlers.get(channel)!; + existingHandlers.delete(channel); + + const cleanedListener = async (event: Electron.IpcMainInvokeEvent, ...args: any[]) => { + // Check if args are wrapped + const newArgs = getArgsFromPayload(args); + // Try the new listener first, then fall back to original if needed + try { + const result = await listener(event, ...newArgs); + return result; + } catch (error) { + // If new listener fails, try original (shouldn't happen, but just in case) + return await originalHandler(event, ...newArgs); + } + }; + return originalHandle(channel, cleanedListener); + } + const cleanedListener = async (event: Electron.IpcMainInvokeEvent, ...args: any[]) => { const newArgs = getArgsFromPayload(args); const result = await listener(event, ...newArgs); @@ -369,6 +455,11 @@ function patchIpcMain() { channel: Channel, listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => Promise | any, ) => { + // Skip wrapping for excluded channels + if (excludedChannels.includes(channel)) { + return originalHandleOnce(channel, listener); + } + const cleanedListener = async (event: Electron.IpcMainInvokeEvent, ...args: any[]) => { const newArgs = getArgsFromPayload(args); const result = await listener(event, ...newArgs); @@ -391,6 +482,12 @@ async function install(options: InstallOptions = {}) { // set log level if (options.logLevel) logger.setLogLevel(options.logLevel); + // Store excluded channels + excludedChannels = [ + ...excludedIpcChannels, + ...(options.excludeChannels || []), + ]; + patchIpcMain(); const installToSession = async (ses: Electron.Session) => { @@ -420,6 +517,15 @@ async function install(options: InstallOptions = {}) { id: 'devtron-renderer-preload', }); + // Set up IPC handler to provide excluded channels to renderer (only once) + // This allows the renderer to conditionally wrap IPC calls + if (!excludedChannelsHandlerRegistered) { + ipcMain.handle('devtron:get-excluded-channels', () => { + return excludedChannels; + }); + excludedChannelsHandlerRegistered = true; + } + // load extension const extensionPath = path.resolve(serviceWorkerPreloadPath, '..', '..', 'extension'); devtron = await ses.extensions.loadExtension(extensionPath, { allowFileAccess: true }); diff --git a/src/lib/electron-renderer-tracker.ts b/src/lib/electron-renderer-tracker.ts index a2fd8bc..7b89dbf 100644 --- a/src/lib/electron-renderer-tracker.ts +++ b/src/lib/electron-renderer-tracker.ts @@ -11,6 +11,48 @@ type IpcListener = (event: Electron.IpcRendererEvent, ...args: any[]) => void; let isInstalled = false; +/** + * Channels that should be excluded from Devtron's payload wrapping. + * This is fetched from the main process via IPC and cached. + */ +let excludedChannelsCache: Channel[] | null = null; +let excludedChannelsPromise: Promise | null = null; + +const getExcludedChannels = (): Channel[] => { + // Return cached value if available + if (excludedChannelsCache !== null) { + return excludedChannelsCache; + } + + // Try to get from global variable (set by main process) + if (typeof window !== 'undefined' && (window as any).__devtronExcludedChannels) { + const channels = (window as any).__devtronExcludedChannels; + if (Array.isArray(channels)) { + excludedChannelsCache = channels; + return excludedChannelsCache; + } + } + + // Fetch via IPC if not already fetching + if (!excludedChannelsPromise && typeof ipcRenderer !== 'undefined') { + excludedChannelsPromise = ipcRenderer + .invoke('devtron:get-excluded-channels') + .then((channels: Channel[]) => { + excludedChannelsCache = channels; + if (typeof window !== 'undefined') { + (window as any).__devtronExcludedChannels = channels; + } + return channels; + }) + .catch(() => { + return []; + }); + } + + // Return empty array for now, will be updated when IPC call completes + return []; +}; + /** * Store tracked listeners in a map so that they can be removed later * if the user calls `removeListener`or `removeAllListeners`. @@ -118,6 +160,12 @@ export function monitorRenderer(): void { }; ipcRenderer.sendSync = function (channel: Channel, ...args: any[]) { + const excludedChannels = getExcludedChannels(); + // Skip wrapping for excluded channels + if (excludedChannels.includes(channel)) { + return originalSendSync(channel, ...args); + } + const uuid = crypto.randomUUID(); // uuid is used to match the response with the request const payload = { __uuid__devtron: uuid, @@ -133,6 +181,12 @@ export function monitorRenderer(): void { }; ipcRenderer.invoke = async function (channel: Channel, ...args: any[]): Promise { + const excludedChannels = getExcludedChannels(); + // Skip wrapping for excluded channels + if (excludedChannels.includes(channel)) { + return originalInvoke(channel, ...args); + } + const uuid = crypto.randomUUID(); // uuid is used to match the response with the request const payload = { __uuid__devtron: uuid, diff --git a/src/lib/renderer-preload.ts b/src/lib/renderer-preload.ts index 0658b2f..cf241b5 100644 --- a/src/lib/renderer-preload.ts +++ b/src/lib/renderer-preload.ts @@ -1,3 +1,12 @@ +import { contextBridge } from 'electron'; import { monitorRenderer } from './electron-renderer-tracker'; +// Expose excluded channels to the renderer +// This will be set by the main process before the preload script runs +if (typeof window !== 'undefined') { + // Try to get excluded channels from a global variable set by main process + // If not available, use an empty array (will be updated via IPC if needed) + (window as any).__devtronExcludedChannels = (window as any).__devtronExcludedChannels || []; +} + monitorRenderer(); diff --git a/src/types/shared.ts b/src/types/shared.ts index 51c2263..f0c21c2 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -52,6 +52,19 @@ export interface InstallOptions { * @default 'debug' */ logLevel?: LogLevelString; + /** + * List of IPC channels that should be excluded from Devtron's payload wrapping. + * Handlers for these channels will receive original arguments instead of wrapped payloads. + * This is useful for libraries that register IPC handlers before devtron.install() is called. + * + * @example + * ```ts + * devtron.install({ + * excludeChannels: ['bugsnag::renderer-to-main', 'bugsnag::renderer-to-main-sync'] + * }); + * ``` + */ + excludeChannels?: Channel[]; } /* ------------------------------------------------------ */