Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 76 additions & 19 deletions src/adapters/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,31 @@ function withRemoval(ipcMain: IpcMain, type: string, listener: Parameters<IpcMai
}

export function createContext(ipcMain: IpcMain, window?: BrowserWindow, options?: {
/**
* When true, only respond to the renderer window that originally sent the request.
* Applies to the `eventa-message` (invoke/response) channel only.
*/
onlySameWindow?: boolean
/**
* 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.
* When a `window` is provided, pushes only to that specific window.
* When no `window` is provided, broadcasts to all windows returned by `getWindows`.
* Set to `false` to disable.
* @default 'eventa-push'
*/
pushEventName?: string | false
/**
* Returns the list of all BrowserWindow instances to broadcast push events to.
* Only used when no `window` is bound to this context (i.e., broadcast mode).
*/
getWindows?: () => BrowserWindow[]
errorEventName?: string | false
extraListeners?: Record<string, (_, event: Event) => void | Promise<void>>
throwIfFailedToSend?: boolean
Expand All @@ -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,
Expand All @@ -42,38 +66,71 @@ export function createContext(ipcMain: IpcMain, window?: BrowserWindow, options?
ctx.on(and(
matchBy('*'),
matchBy((e: DirectionalEventa<any>) => 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
}
}
})
Expand Down
56 changes: 44 additions & 12 deletions src/adapters/electron/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,29 @@ 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<string, IpcRendererListener>
}) {
const ctx = createBaseContext() as EventContext<any, { raw: { ipcRendererEvent: Electron.IpcRendererEvent, event: Event | unknown } }>

const {
messageEventName = 'eventa-message',
pushEventName = 'eventa-push',
errorEventName = 'eventa-error',
extraListeners = {},
} = options || {}
Expand All @@ -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<Eventa<any>>(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<Eventa<any>>(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)) {
Expand Down