Skip to content
Merged
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
17 changes: 4 additions & 13 deletions src/main/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,15 @@ describe('main/events', () => {
it('onMainEvent registers ipcMain.on listener', () => {
const listenerMock = vi.fn();

onMainEvent(
EVENTS.WINDOW_SHOW,
listenerMock as unknown as (e: Electron.IpcMainEvent, d: unknown) => void,
);
onMainEvent(EVENTS.WINDOW_SHOW, listenerMock);

expect(onMock).toHaveBeenCalledWith(EVENTS.WINDOW_SHOW, listenerMock);
});

it('handleMainEvent registers ipcMain.handle listener', () => {
const listenerMock = vi.fn();
const listenerMock = vi.fn(() => 'v1.2.3');

handleMainEvent(
EVENTS.VERSION,
listenerMock as unknown as (
e: Electron.IpcMainInvokeEvent,
d: unknown,
) => void,
);
handleMainEvent(EVENTS.VERSION, listenerMock);

expect(handleMock).toHaveBeenCalledWith(EVENTS.VERSION, listenerMock);
});
Expand All @@ -63,6 +54,6 @@ describe('main/events', () => {

sendRendererEvent(mb as unknown as Menubar, EVENTS.RESET_APP);

expect(sendMock).toHaveBeenCalledWith(EVENTS.RESET_APP, undefined);
expect(sendMock).toHaveBeenCalledWith(EVENTS.RESET_APP);
});
});
48 changes: 22 additions & 26 deletions src/main/events.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,50 @@
import { ipcMain } from 'electron';
import type { Menubar } from 'menubar';

import type { EventData, EventType } from '../shared/events';
import type {
EventArgs,
EventRequest,
EventResponse,
EventType,
} from '../shared/events';

/**
* Register a fire-and-forget IPC listener on the main process (ipcMain.on).
* Use this when the renderer sends a one-way message and no return value is needed.
*
* @param event - The IPC channel/event name to listen on.
* @param listener - Callback invoked when the event is received.
*/
export function onMainEvent<T = EventData>(
event: EventType,
listener: (event: Electron.IpcMainEvent, args: T) => void,
) {
export function onMainEvent<E extends EventType>(
event: E,
listener: (event: Electron.IpcMainEvent, data: EventRequest<E>) => void,
): void {
ipcMain.on(event, listener as Parameters<typeof ipcMain.on>[1]);
}

/**
* Register a request/response IPC handler on the main process (ipcMain.handle).
* Use this when the renderer invokes a channel and expects a value back.
*
* @param event - The IPC channel/event name to handle.
* @param listener - Callback whose return value is sent back to the renderer.
* The listener's return type is enforced by the event's contract.
*/
export function handleMainEvent<T = EventData>(
event: EventType,
export function handleMainEvent<E extends EventType>(
event: E,
listener: (
event: Electron.IpcMainInvokeEvent,
data: T,
) => unknown | Promise<unknown>,
) {
data: EventRequest<E>,
) => EventResponse<E> | Promise<EventResponse<E>>,
): void {
ipcMain.handle(event, listener as Parameters<typeof ipcMain.handle>[1]);
}

/**
* Push an event from the main process to the renderer via webContents.
*
* @param mb - The menubar instance whose window receives the event.
* @param event - The IPC channel/event name to emit.
* @param data - Optional payload sent with the event.
* Variadic so events without a payload can be called as `sendRendererEvent(mb, event)`.
*/
export function sendRendererEvent(
export function sendRendererEvent<E extends EventType>(
mb: Menubar,
event: EventType,
data?: string,
) {
event: E,
...args: EventArgs<E>
): void {
if (!mb.window) {
return;
}

mb.window.webContents.send(event, data);
mb.window.webContents.send(event, ...args);
}
16 changes: 4 additions & 12 deletions src/main/handlers/system.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import { app, globalShortcut, shell } from 'electron';
import type { Menubar } from 'menubar';

import {
EVENTS,
type EventData,
type IAutoLaunch,
type IKeyboardShortcut,
type IKeyboardShortcutResult,
type IOpenExternal,
} from '../../shared/events';
import { EVENTS } from '../../shared/events';

import { handleMainEvent, onMainEvent } from '../events';

Expand Down Expand Up @@ -38,7 +31,7 @@ export function registerSystemHandlers(mb: Menubar): void {
/**
* Open the given URL in the user's default browser, with an option to activate the app.
*/
onMainEvent(EVENTS.OPEN_EXTERNAL, (_, { url, activate }: IOpenExternal) =>
onMainEvent(EVENTS.OPEN_EXTERNAL, (_, { url, activate }) =>
shell.openExternal(url, { activate }),
);

Expand All @@ -47,8 +40,7 @@ export function registerSystemHandlers(mb: Menubar): void {
*/
handleMainEvent(
EVENTS.UPDATE_KEYBOARD_SHORTCUT,
(_, data: EventData): IKeyboardShortcutResult => {
const { enabled, keyboardShortcut } = data as IKeyboardShortcut;
(_, { enabled, keyboardShortcut }) => {
const previous = lastRegisteredAccelerator;

if (lastRegisteredAccelerator) {
Expand Down Expand Up @@ -77,7 +69,7 @@ export function registerSystemHandlers(mb: Menubar): void {
/**
* Update the application's auto-launch setting based on the provided configuration.
*/
onMainEvent(EVENTS.UPDATE_AUTO_LAUNCH, (_, settings: IAutoLaunch) => {
onMainEvent(EVENTS.UPDATE_AUTO_LAUNCH, (_, settings) => {
app.setLoginItemSettings(settings);
});
}
17 changes: 3 additions & 14 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import { contextBridge, webFrame } from 'electron';

import type {
IKeyboardShortcut,
IKeyboardShortcutResult,
} from '../shared/events';
import type { IKeyboardShortcut } from '../shared/events';
import { EVENTS } from '../shared/events';
import { isLinux, isMacOS, isWindows } from '../shared/platform';

import {
invokeMainEvent,
invokeMainEventWithData,
onRendererEvent,
sendMainEvent,
} from './utils';
import { invokeMainEvent, onRendererEvent, sendMainEvent } from './utils';

/**
* The Gitify Bridge API exposed to the renderer via `contextBridge`.
Expand Down Expand Up @@ -70,10 +62,7 @@ export const api = {
* @returns Whether registration succeeded (when enabled).
*/
applyKeyboardShortcut: (payload: IKeyboardShortcut) =>
invokeMainEventWithData(
EVENTS.UPDATE_KEYBOARD_SHORTCUT,
payload,
) as Promise<IKeyboardShortcutResult>,
invokeMainEvent(EVENTS.UPDATE_KEYBOARD_SHORTCUT, payload),

/** Tray icon controls. */
tray: {
Expand Down
32 changes: 10 additions & 22 deletions src/preload/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import { EVENTS } from '../shared/events';

import {
invokeMainEvent,
invokeMainEventWithData,
onRendererEvent,
sendMainEvent,
} from './utils';
import { invokeMainEvent, onRendererEvent, sendMainEvent } from './utils';

vi.mock('electron', () => {
type Listener = (event: unknown, ...args: unknown[]) => void;
Expand Down Expand Up @@ -37,25 +32,24 @@ describe('preload/utils', () => {
it('sendMainEvent forwards to ipcRenderer.send', () => {
sendMainEvent(EVENTS.WINDOW_SHOW);

expect(ipcRenderer.send).toHaveBeenCalledWith(
EVENTS.WINDOW_SHOW,
undefined,
);
expect(ipcRenderer.send).toHaveBeenCalledWith(EVENTS.WINDOW_SHOW);
});

it('invokeMainEvent forwards and resolves', async () => {
const result = await invokeMainEvent(EVENTS.VERSION, 'data');
it('invokeMainEvent forwards a no-payload event and resolves', async () => {
const result = await invokeMainEvent(EVENTS.VERSION);

expect(ipcRenderer.invoke).toHaveBeenCalledWith(EVENTS.VERSION, 'data');
expect(ipcRenderer.invoke).toHaveBeenCalledWith(EVENTS.VERSION);
expect(result).toBe('response');
});

it('invokeMainEventWithData forwards structured payload and resolves', async () => {
it('invokeMainEvent forwards a structured payload and resolves', async () => {
const payload = { enabled: true, keyboardShortcut: 'CommandOrControl+G' };
const result = await invokeMainEventWithData(

const result = await invokeMainEvent(
EVENTS.UPDATE_KEYBOARD_SHORTCUT,
payload,
);

expect(ipcRenderer.invoke).toHaveBeenCalledWith(
EVENTS.UPDATE_KEYBOARD_SHORTCUT,
payload,
Expand All @@ -65,13 +59,7 @@ describe('preload/utils', () => {

it('onRendererEvent registers listener and receives emitted data', () => {
const handlerMock = vi.fn();
onRendererEvent(
EVENTS.UPDATE_ICON_TITLE,
handlerMock as unknown as (
e: Electron.IpcRendererEvent,
args: string,
) => void,
);
onRendererEvent(EVENTS.UPDATE_ICON_TITLE, handlerMock);
(
ipcRenderer as unknown as {
__emit: (channel: string, ...a: unknown[]) => void;
Expand Down
56 changes: 23 additions & 33 deletions src/preload/utils.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,46 @@
import { ipcRenderer } from 'electron';

import type { EventData, EventType } from '../shared/events';
import type {
EventArgs,
EventRequest,
EventResponse,
EventType,
} from '../shared/events';

/**
* Send a fire-and-forget IPC message from the renderer to the main process.
*
* @param event - The IPC event type to send.
* @param data - Optional payload to include with the event.
* Variadic so events without a payload can be called as `sendMainEvent(event)`.
*/
export function sendMainEvent(event: EventType, data?: EventData): void {
ipcRenderer.send(event, data);
export function sendMainEvent<E extends EventType>(
event: E,
...args: EventArgs<E>
): void {
ipcRenderer.send(event, ...args);
}

/**
* Send an IPC message from the renderer to the main process and await a response.
*
* @param event - The IPC event type to invoke.
* @param data - Optional string payload to include with the event.
* @returns A promise that resolves to the string response from the main process.
* The resolved value type is enforced by the event's contract.
*/
export async function invokeMainEvent(
event: EventType,
data?: string,
): Promise<string> {
export async function invokeMainEvent<E extends EventType>(
event: E,
...args: EventArgs<E>
): Promise<EventResponse<E>> {
try {
return await ipcRenderer.invoke(event, data);
return await ipcRenderer.invoke(event, ...args);
} catch (err) {
// biome-ignore lint/suspicious/noConsole: preload environment is strictly sandboxed
console.error(`[IPC] invoke failed: ${event}`, err);
throw err;
}
}

/**
* Invoke a main-process handler with structured `EventData` and await the result.
*/
export function invokeMainEventWithData(
event: EventType,
data?: EventData,
): Promise<unknown> {
return ipcRenderer.invoke(event, data);
}

/**
* Register a listener for an IPC event sent from the main process to the renderer.
*
* @param event - The IPC event type to listen for.
* @param listener - The callback invoked when the event is received.
*/
export function onRendererEvent(
event: EventType,
listener: (event: Electron.IpcRendererEvent, args: string) => void,
) {
ipcRenderer.on(event, listener);
export function onRendererEvent<E extends EventType>(
event: E,
listener: (event: Electron.IpcRendererEvent, data: EventRequest<E>) => void,
): void {
ipcRenderer.on(event, listener as Parameters<typeof ipcRenderer.on>[1]);
}
Loading
Loading