diff --git a/src/main/events.test.ts b/src/main/events.test.ts index 0f6bdd675..063661f42 100644 --- a/src/main/events.test.ts +++ b/src/main/events.test.ts @@ -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); }); @@ -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); }); }); diff --git a/src/main/events.ts b/src/main/events.ts index 38dcb8687..3fee58b14 100644 --- a/src/main/events.ts +++ b/src/main/events.ts @@ -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( - event: EventType, - listener: (event: Electron.IpcMainEvent, args: T) => void, -) { +export function onMainEvent( + event: E, + listener: (event: Electron.IpcMainEvent, data: EventRequest) => void, +): void { ipcMain.on(event, listener as Parameters[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( - event: EventType, +export function handleMainEvent( + event: E, listener: ( event: Electron.IpcMainInvokeEvent, - data: T, - ) => unknown | Promise, -) { + data: EventRequest, + ) => EventResponse | Promise>, +): void { ipcMain.handle(event, listener as Parameters[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( mb: Menubar, - event: EventType, - data?: string, -) { + event: E, + ...args: EventArgs +): void { if (!mb.window) { return; } - mb.window.webContents.send(event, data); + mb.window.webContents.send(event, ...args); } diff --git a/src/main/handlers/system.ts b/src/main/handlers/system.ts index 07eca1615..5a851be50 100644 --- a/src/main/handlers/system.ts +++ b/src/main/handlers/system.ts @@ -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'; @@ -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 }), ); @@ -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) { @@ -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); }); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 055fc13d1..f4c79e0e0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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`. @@ -70,10 +62,7 @@ export const api = { * @returns Whether registration succeeded (when enabled). */ applyKeyboardShortcut: (payload: IKeyboardShortcut) => - invokeMainEventWithData( - EVENTS.UPDATE_KEYBOARD_SHORTCUT, - payload, - ) as Promise, + invokeMainEvent(EVENTS.UPDATE_KEYBOARD_SHORTCUT, payload), /** Tray icon controls. */ tray: { diff --git a/src/preload/utils.test.ts b/src/preload/utils.test.ts index 84eec62b0..890b29ca7 100644 --- a/src/preload/utils.test.ts +++ b/src/preload/utils.test.ts @@ -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; @@ -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, @@ -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; diff --git a/src/preload/utils.ts b/src/preload/utils.ts index 3b7694df6..bc9138dbf 100644 --- a/src/preload/utils.ts +++ b/src/preload/utils.ts @@ -1,30 +1,33 @@ 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( + event: E, + ...args: EventArgs +): 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 { +export async function invokeMainEvent( + event: E, + ...args: EventArgs +): Promise> { 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); @@ -32,25 +35,12 @@ export async function invokeMainEvent( } } -/** - * Invoke a main-process handler with structured `EventData` and await the result. - */ -export function invokeMainEventWithData( - event: EventType, - data?: EventData, -): Promise { - 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( + event: E, + listener: (event: Electron.IpcRendererEvent, data: EventRequest) => void, +): void { + ipcRenderer.on(event, listener as Parameters[1]); } diff --git a/src/shared/events.ts b/src/shared/events.ts index dc2a34e39..07832bf2e 100644 --- a/src/shared/events.ts +++ b/src/shared/events.ts @@ -1,10 +1,8 @@ -import { APPLICATION } from './constants'; - -const P = APPLICATION.EVENT_PREFIX; +const P = 'gitify:' as const; /** * IPC event name constants for all Electron main ↔ renderer communication channels. - * Each value is prefixed with `APPLICATION.EVENT_PREFIX` to prevent collisions. + * Each value is prefixed with `gitify:` to prevent collisions. */ export const EVENTS = { AUTH_CALLBACK: `${P}auth-callback`, @@ -53,11 +51,59 @@ export interface IOpenExternal { activate: boolean; } -/** Union of all possible IPC event payload types. */ -export type EventData = - | string - | number - | boolean - | IKeyboardShortcut - | IAutoLaunch - | IOpenExternal; +/** Shape of a single event contract: a request payload and a response payload. */ +type Contract = { request: unknown; response: unknown }; + +/** + * Type-level guard that forces every event in `EVENTS` to have a contract entry. + * If a key is missing, this constraint fails at compile time. + */ +type AssertEventCoverage> = T; + +/** + * Compile-time contract for every IPC event: request payload type and response type. + * + * - For `handle`/`invoke` pairs, `response` is the return type the renderer awaits. + * - For fire-and-forget events (`send`/`on`), `response` is `undefined`. + * - For events with no payload, `request` is `undefined`. + */ +export type EventContracts = AssertEventCoverage<{ + [EVENTS.AUTH_CALLBACK]: { request: string; response: undefined }; + [EVENTS.QUIT]: { request: undefined; response: undefined }; + [EVENTS.WINDOW_SHOW]: { request: undefined; response: undefined }; + [EVENTS.WINDOW_HIDE]: { request: undefined; response: undefined }; + [EVENTS.VERSION]: { request: undefined; response: string }; + [EVENTS.UPDATE_ICON_COLOR]: { request: number; response: undefined }; + [EVENTS.UPDATE_ICON_TITLE]: { request: string; response: undefined }; + [EVENTS.USE_ALTERNATE_IDLE_ICON]: { request: boolean; response: undefined }; + [EVENTS.USE_UNREAD_ACTIVE_ICON]: { request: boolean; response: undefined }; + [EVENTS.UPDATE_KEYBOARD_SHORTCUT]: { + request: IKeyboardShortcut; + response: IKeyboardShortcutResult; + }; + [EVENTS.UPDATE_AUTO_LAUNCH]: { request: IAutoLaunch; response: undefined }; + [EVENTS.SAFE_STORAGE_ENCRYPT]: { request: string; response: string }; + [EVENTS.SAFE_STORAGE_DECRYPT]: { request: string; response: string }; + [EVENTS.NOTIFICATION_SOUND_PATH]: { request: undefined; response: string }; + [EVENTS.OPEN_EXTERNAL]: { request: IOpenExternal; response: undefined }; + [EVENTS.RESET_APP]: { request: undefined; response: undefined }; + [EVENTS.UPDATE_THEME]: { request: string; response: undefined }; + [EVENTS.TWEMOJI_DIRECTORY]: { request: undefined; response: string }; +}>; + +/** Request payload type for a given event. */ +export type EventRequest = EventContracts[E]['request']; + +/** Response payload type for a given event. */ +export type EventResponse = EventContracts[E]['response']; + +/** + * Variadic args helper: yields `[]` when the event has no request payload, + * otherwise `[request]`. Lets callers write `send(EVENTS.QUIT)` instead of + * `send(EVENTS.QUIT, undefined)`. + */ +export type EventArgs = [EventRequest] extends [ + undefined, +] + ? [] + : [EventRequest];