From 4c3847010c15d39bb6374417b33e36e6baa204d7 Mon Sep 17 00:00:00 2001 From: leafyy Date: Sun, 8 Mar 2026 14:40:15 +0800 Subject: [PATCH 1/3] fix: add guard for undefined options in same-window event filtering --- src/adapters/electron/main.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/adapters/electron/main.ts b/src/adapters/electron/main.ts index f3eff3a..7e5e224 100644 --- a/src/adapters/electron/main.ts +++ b/src/adapters/electron/main.ts @@ -52,7 +52,13 @@ export function createContext(ipcMain: IpcMain, window?: BrowserWindow, options? } if (onlySameWindow) { - if (window.webContents.id === options?.raw.ipcMainEvent.sender.id) { + // NOTICE: When the main process emits events proactively (e.g. a cursor screen point + // polling loop), there is no originating IPC message, so `options` is undefined and + // `options?.raw.ipcMainEvent` does not exist. + // Without this guard the condition `window.webContents.id === undefined` is always + // false, silently dropping every push-style event sent from the main process. + // Only apply the same-window filter when the emit originates from a renderer request. + if (!options?.raw?.ipcMainEvent || window.webContents.id === options.raw.ipcMainEvent.sender.id) { window?.webContents?.send(messageEventName, eventBody) } } From 660bc67c63769e492bd1a991a2aadd4d31b2e635 Mon Sep 17 00:00:00 2001 From: leafyy Date: Mon, 9 Mar 2026 23:10:07 +0800 Subject: [PATCH 2/3] feat: enhance IPC context with push event support and shared message handling --- src/adapters/electron/main.ts | 101 ++++++++++++++++++++++-------- src/adapters/electron/renderer.ts | 56 +++++++++++++---- 2 files changed, 120 insertions(+), 37 deletions(-) diff --git a/src/adapters/electron/main.ts b/src/adapters/electron/main.ts index 7e5e224..5cc7fec 100644 --- a/src/adapters/electron/main.ts +++ b/src/adapters/electron/main.ts @@ -19,8 +19,31 @@ function withRemoval(ipcMain: IpcMain, type: string, listener: Parameters BrowserWindow[] errorEventName?: string | false extraListeners?: Record void | Promise> throwIfFailedToSend?: boolean @@ -32,6 +55,7 @@ export function createContext(ipcMain: IpcMain, window?: BrowserWindow, options? const { messageEventName = 'eventa-message', + pushEventName = 'eventa-push', errorEventName = 'eventa-error', extraListeners = {}, onlySameWindow = false, @@ -42,44 +66,71 @@ export function createContext(ipcMain: IpcMain, window?: BrowserWindow, options? ctx.on(and( matchBy('*'), matchBy((e: DirectionalEventa) => e._flowDirection === EventaFlowDirection.Outbound || !e._flowDirection), - ), (event, options) => { + ), (event, callOptions) => { const eventBody = generatePayload(event.id, { ...defineOutboundEventa(event.type), ...event }) - if (messageEventName !== false) { - try { + + // NOTICE: Two-channel routing splits on whether the emit originated from a renderer request. + // + // - eventa-message: invoke/response channel (renderer ↔ main). + // `onlySameWindow` filtering applies here to prevent cross-window response leakage. + // + // - eventa-push: main-process proactive push channel (main → renderer). + // Delivers to either a specific bound window or all windows (broadcast). + // `onlySameWindow` does NOT apply here — there is no originating renderer request. + const isResponseToRenderer = !!callOptions?.raw?.ipcMainEvent + + try { + if (isResponseToRenderer) { + // Invoke/response path: reply on eventa-message, respect onlySameWindow + if (!messageEventName) { + return + } + if (window != null) { if (window.isDestroyed()) { return } - - if (onlySameWindow) { - // NOTICE: When the main process emits events proactively (e.g. a cursor screen point - // polling loop), there is no originating IPC message, so `options` is undefined and - // `options?.raw.ipcMainEvent` does not exist. - // Without this guard the condition `window.webContents.id === undefined` is always - // false, silently dropping every push-style event sent from the main process. - // Only apply the same-window filter when the emit originates from a renderer request. - if (!options?.raw?.ipcMainEvent || window.webContents.id === options.raw.ipcMainEvent.sender.id) { - window?.webContents?.send(messageEventName, eventBody) - } - } - else { - window?.webContents?.send(messageEventName, eventBody) + if (onlySameWindow && window.webContents.id !== callOptions!.raw.ipcMainEvent.sender.id) { + return } + window.webContents.send(messageEventName, eventBody) } else { - if (options?.raw.ipcMainEvent.sender.isDestroyed()) { + if (callOptions!.raw.ipcMainEvent.sender.isDestroyed()) { return } - - options?.raw.ipcMainEvent.sender.send(messageEventName, eventBody) + callOptions!.raw.ipcMainEvent.sender.send(messageEventName, eventBody) } } - catch (error) { - // NOTICE: Electron may throw if the window is closed before sending - // ignore the error if it's about destroyed object - if (!(error instanceof Error) || error?.message !== 'Object has been destroyed') { - throw error + else { + // Proactive push path: emit on eventa-push + if (!pushEventName) { + return } + + if (window != null) { + // Specific window push — deliver only to the bound window + if (window.isDestroyed()) { + return + } + window.webContents.send(pushEventName, eventBody) + } + else { + // Broadcast push — deliver to all windows provided by getWindows + const targets = options?.getWindows?.() ?? [] + for (const win of targets) { + if (!win.isDestroyed()) { + win.webContents.send(pushEventName, eventBody) + } + } + } + } + } + catch (error) { + // NOTICE: Electron may throw if the window is closed before sending + // ignore the error if it's about destroyed object + if (!(error instanceof Error) || error?.message !== 'Object has been destroyed') { + throw error } } }) diff --git a/src/adapters/electron/renderer.ts b/src/adapters/electron/renderer.ts index aed047d..3981b96 100644 --- a/src/adapters/electron/renderer.ts +++ b/src/adapters/electron/renderer.ts @@ -9,7 +9,21 @@ import { generatePayload, parsePayload } from './internal' import { errorEvent } from './shared' export function createContext(ipcRenderer: IpcRenderer, options?: { + /** + * IPC channel name for bidirectional invoke/response communication. + * Renderer sends requests here; main replies here. + * Set to `false` to disable. + * @default 'eventa-message' + */ messageEventName?: string | false + /** + * IPC channel name for main-process proactive push events. + * The renderer listens on this channel to receive push events from main, + * regardless of whether they target a specific window or are broadcast to all windows. + * Set to `false` to disable. + * @default 'eventa-push' + */ + pushEventName?: string | false errorEventName?: string | false extraListeners?: Record }) { @@ -17,6 +31,7 @@ export function createContext(ipcRenderer: IpcRenderer, options?: { const { messageEventName = 'eventa-message', + pushEventName = 'eventa-push', errorEventName = 'eventa-error', extraListeners = {}, } = options || {} @@ -40,23 +55,40 @@ export function createContext(ipcRenderer: IpcRenderer, options?: { } }) + // NOTICE: Shared handler for all incoming events from the main process. + // Both eventa-message (invoke/response) and eventa-push (proactive push) carry + // identical payload formats and are handled identically on the renderer side. + // The channel separation is only meaningful on the main-process side for routing. + function handleIncomingMessage(ipcRendererEvent: Electron.IpcRendererEvent, event: Event | unknown) { + try { + const { type, payload } = parsePayload>(event) + ctx.emit(defineInboundEventa(type), payload.body, { raw: { ipcRendererEvent, event } }) + } + catch (error) { + console.error('Failed to parse IpcRenderer message:', error) + ctx.emit(errorEvent, { error }, { raw: { ipcRendererEvent, event } }) + } + } + if (messageEventName) { - ipcRenderer.on(messageEventName, (ipcRendererEvent, event) => { - try { - const { type, payload } = parsePayload>(event) - ctx.emit(defineInboundEventa(type), payload.body, { raw: { ipcRendererEvent, event } }) - } - catch (error) { - console.error('Failed to parse IpcRenderer message:', error) - ctx.emit(errorEvent, { error }, { raw: { ipcRendererEvent, event } }) - } - }) + ipcRenderer.on(messageEventName, handleIncomingMessage) + cleanupRemoval.push({ remove: () => ipcRenderer.removeListener(messageEventName, handleIncomingMessage) }) + } + + // Listen on the push channel for proactive main-process events. + // This covers both specific-window and broadcast delivery modes — the renderer + // does not need to distinguish between them. + if (pushEventName) { + ipcRenderer.on(pushEventName, handleIncomingMessage) + cleanupRemoval.push({ remove: () => ipcRenderer.removeListener(pushEventName, handleIncomingMessage) }) } if (errorEventName) { - ipcRenderer.on(errorEventName, (ipcRendererEvent, error) => { + const handleErrorMessage: IpcRendererListener = (ipcRendererEvent, error) => { ctx.emit(errorEvent, { error }, { raw: { ipcRendererEvent, event: error } }) - }) + } + ipcRenderer.on(errorEventName, handleErrorMessage) + cleanupRemoval.push({ remove: () => ipcRenderer.removeListener(errorEventName, handleErrorMessage) }) } for (const [eventName, listener] of Object.entries(extraListeners)) { From 5c83463e7b19646f5d5dad2282b4b8cd77cd123c Mon Sep 17 00:00:00 2001 From: leafyy Date: Sun, 8 Mar 2026 14:40:15 +0800 Subject: [PATCH 3/3] fix: add guard for undefined options in same-window event filtering feat: enhance IPC context with push event support and shared message handling --- src/adapters/electron/main.ts | 95 ++++++++++++++++++++++++------- src/adapters/electron/renderer.ts | 56 ++++++++++++++---- 2 files changed, 120 insertions(+), 31 deletions(-) diff --git a/src/adapters/electron/main.ts b/src/adapters/electron/main.ts index f3eff3a..5cc7fec 100644 --- a/src/adapters/electron/main.ts +++ b/src/adapters/electron/main.ts @@ -19,8 +19,31 @@ function withRemoval(ipcMain: IpcMain, type: string, listener: Parameters BrowserWindow[] errorEventName?: string | false extraListeners?: Record void | Promise> throwIfFailedToSend?: boolean @@ -32,6 +55,7 @@ export function createContext(ipcMain: IpcMain, window?: BrowserWindow, options? const { messageEventName = 'eventa-message', + pushEventName = 'eventa-push', errorEventName = 'eventa-error', extraListeners = {}, onlySameWindow = false, @@ -42,38 +66,71 @@ export function createContext(ipcMain: IpcMain, window?: BrowserWindow, options? ctx.on(and( matchBy('*'), matchBy((e: DirectionalEventa) => e._flowDirection === EventaFlowDirection.Outbound || !e._flowDirection), - ), (event, options) => { + ), (event, callOptions) => { const eventBody = generatePayload(event.id, { ...defineOutboundEventa(event.type), ...event }) - if (messageEventName !== false) { - try { + + // NOTICE: Two-channel routing splits on whether the emit originated from a renderer request. + // + // - eventa-message: invoke/response channel (renderer ↔ main). + // `onlySameWindow` filtering applies here to prevent cross-window response leakage. + // + // - eventa-push: main-process proactive push channel (main → renderer). + // Delivers to either a specific bound window or all windows (broadcast). + // `onlySameWindow` does NOT apply here — there is no originating renderer request. + const isResponseToRenderer = !!callOptions?.raw?.ipcMainEvent + + try { + if (isResponseToRenderer) { + // Invoke/response path: reply on eventa-message, respect onlySameWindow + if (!messageEventName) { + return + } + if (window != null) { if (window.isDestroyed()) { return } - - if (onlySameWindow) { - if (window.webContents.id === options?.raw.ipcMainEvent.sender.id) { - window?.webContents?.send(messageEventName, eventBody) - } - } - else { - window?.webContents?.send(messageEventName, eventBody) + if (onlySameWindow && window.webContents.id !== callOptions!.raw.ipcMainEvent.sender.id) { + return } + window.webContents.send(messageEventName, eventBody) } else { - if (options?.raw.ipcMainEvent.sender.isDestroyed()) { + if (callOptions!.raw.ipcMainEvent.sender.isDestroyed()) { return } - - options?.raw.ipcMainEvent.sender.send(messageEventName, eventBody) + callOptions!.raw.ipcMainEvent.sender.send(messageEventName, eventBody) } } - catch (error) { - // NOTICE: Electron may throw if the window is closed before sending - // ignore the error if it's about destroyed object - if (!(error instanceof Error) || error?.message !== 'Object has been destroyed') { - throw error + else { + // Proactive push path: emit on eventa-push + if (!pushEventName) { + return } + + if (window != null) { + // Specific window push — deliver only to the bound window + if (window.isDestroyed()) { + return + } + window.webContents.send(pushEventName, eventBody) + } + else { + // Broadcast push — deliver to all windows provided by getWindows + const targets = options?.getWindows?.() ?? [] + for (const win of targets) { + if (!win.isDestroyed()) { + win.webContents.send(pushEventName, eventBody) + } + } + } + } + } + catch (error) { + // NOTICE: Electron may throw if the window is closed before sending + // ignore the error if it's about destroyed object + if (!(error instanceof Error) || error?.message !== 'Object has been destroyed') { + throw error } } }) diff --git a/src/adapters/electron/renderer.ts b/src/adapters/electron/renderer.ts index aed047d..3981b96 100644 --- a/src/adapters/electron/renderer.ts +++ b/src/adapters/electron/renderer.ts @@ -9,7 +9,21 @@ import { generatePayload, parsePayload } from './internal' import { errorEvent } from './shared' export function createContext(ipcRenderer: IpcRenderer, options?: { + /** + * IPC channel name for bidirectional invoke/response communication. + * Renderer sends requests here; main replies here. + * Set to `false` to disable. + * @default 'eventa-message' + */ messageEventName?: string | false + /** + * IPC channel name for main-process proactive push events. + * The renderer listens on this channel to receive push events from main, + * regardless of whether they target a specific window or are broadcast to all windows. + * Set to `false` to disable. + * @default 'eventa-push' + */ + pushEventName?: string | false errorEventName?: string | false extraListeners?: Record }) { @@ -17,6 +31,7 @@ export function createContext(ipcRenderer: IpcRenderer, options?: { const { messageEventName = 'eventa-message', + pushEventName = 'eventa-push', errorEventName = 'eventa-error', extraListeners = {}, } = options || {} @@ -40,23 +55,40 @@ export function createContext(ipcRenderer: IpcRenderer, options?: { } }) + // NOTICE: Shared handler for all incoming events from the main process. + // Both eventa-message (invoke/response) and eventa-push (proactive push) carry + // identical payload formats and are handled identically on the renderer side. + // The channel separation is only meaningful on the main-process side for routing. + function handleIncomingMessage(ipcRendererEvent: Electron.IpcRendererEvent, event: Event | unknown) { + try { + const { type, payload } = parsePayload>(event) + ctx.emit(defineInboundEventa(type), payload.body, { raw: { ipcRendererEvent, event } }) + } + catch (error) { + console.error('Failed to parse IpcRenderer message:', error) + ctx.emit(errorEvent, { error }, { raw: { ipcRendererEvent, event } }) + } + } + if (messageEventName) { - ipcRenderer.on(messageEventName, (ipcRendererEvent, event) => { - try { - const { type, payload } = parsePayload>(event) - ctx.emit(defineInboundEventa(type), payload.body, { raw: { ipcRendererEvent, event } }) - } - catch (error) { - console.error('Failed to parse IpcRenderer message:', error) - ctx.emit(errorEvent, { error }, { raw: { ipcRendererEvent, event } }) - } - }) + ipcRenderer.on(messageEventName, handleIncomingMessage) + cleanupRemoval.push({ remove: () => ipcRenderer.removeListener(messageEventName, handleIncomingMessage) }) + } + + // Listen on the push channel for proactive main-process events. + // This covers both specific-window and broadcast delivery modes — the renderer + // does not need to distinguish between them. + if (pushEventName) { + ipcRenderer.on(pushEventName, handleIncomingMessage) + cleanupRemoval.push({ remove: () => ipcRenderer.removeListener(pushEventName, handleIncomingMessage) }) } if (errorEventName) { - ipcRenderer.on(errorEventName, (ipcRendererEvent, error) => { + const handleErrorMessage: IpcRendererListener = (ipcRendererEvent, error) => { ctx.emit(errorEvent, { error }, { raw: { ipcRendererEvent, event: error } }) - }) + } + ipcRenderer.on(errorEventName, handleErrorMessage) + cleanupRemoval.push({ remove: () => ipcRenderer.removeListener(errorEventName, handleErrorMessage) }) } for (const [eventName, listener] of Object.entries(extraListeners)) {