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
5 changes: 4 additions & 1 deletion src/main/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ export function onMainEvent(
*/
export function handleMainEvent(
event: EventType,
listener: (event: Electron.IpcMainInvokeEvent, data: EventData) => void,
listener: (
event: Electron.IpcMainInvokeEvent,
data: EventData,
) => unknown | Promise<unknown>,
) {
ipcMain.handle(event, listener);
}
Expand Down
118 changes: 114 additions & 4 deletions src/main/handlers/system.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { globalShortcut } from 'electron';
import type { Menubar } from 'menubar';

import { EVENTS } from '../../shared/events';

import { registerSystemHandlers } from './system';

const onMock = vi.fn();
const handleMock = vi.fn();

vi.mock('electron', () => ({
ipcMain: {
on: (...args: unknown[]) => onMock(...args),
handle: (...args: unknown[]) => handleMock(...args),
},
globalShortcut: {
register: vi.fn(),
Expand All @@ -27,6 +30,7 @@ describe('main/handlers/system.ts', () => {

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(globalShortcut.register).mockReturnValue(true);

menubar = {
showWindow: vi.fn(),
Expand All @@ -37,6 +41,20 @@ describe('main/handlers/system.ts', () => {
} as unknown as Menubar;
});

function getKeyboardShortcutHandler() {
registerSystemHandlers(menubar);
const handleCall = handleMock.mock.calls.find(
(c) => c[0] === EVENTS.UPDATE_KEYBOARD_SHORTCUT,
);
if (!handleCall) {
throw new Error('UPDATE_KEYBOARD_SHORTCUT handler not registered');
}
return handleCall[1] as (
event: Electron.IpcMainInvokeEvent,
data: { enabled: boolean; keyboardShortcut: string },
) => { success: boolean };
}

describe('registerSystemHandlers', () => {
it('registers handlers without throwing', () => {
expect(() => registerSystemHandlers(menubar)).not.toThrow();
Expand All @@ -45,13 +63,105 @@ describe('main/handlers/system.ts', () => {
it('registers expected system IPC event handlers', () => {
registerSystemHandlers(menubar);

const registeredEvents = onMock.mock.calls.map(
const onEvents = onMock.mock.calls.map((call: [string]) => call[0]);
const handleEvents = handleMock.mock.calls.map(
(call: [string]) => call[0],
);

expect(registeredEvents).toContain(EVENTS.OPEN_EXTERNAL);
expect(registeredEvents).toContain(EVENTS.UPDATE_KEYBOARD_SHORTCUT);
expect(registeredEvents).toContain(EVENTS.UPDATE_AUTO_LAUNCH);
expect(onEvents).toContain(EVENTS.OPEN_EXTERNAL);
expect(onEvents).toContain(EVENTS.UPDATE_AUTO_LAUNCH);
expect(handleEvents).toContain(EVENTS.UPDATE_KEYBOARD_SHORTCUT);
});
});

describe('UPDATE_KEYBOARD_SHORTCUT', () => {
it('registers shortcut when enabled', () => {
const handler = getKeyboardShortcutHandler();

const result = handler({} as Electron.IpcMainInvokeEvent, {
enabled: true,
keyboardShortcut: 'CommandOrControl+Shift+G',
});

expect(result).toEqual({ success: true });
expect(globalShortcut.register).toHaveBeenCalledWith(
'CommandOrControl+Shift+G',
expect.any(Function),
);
});

it('unregisters when disabled after being enabled', () => {
const handler = getKeyboardShortcutHandler();

handler({} as Electron.IpcMainInvokeEvent, {
enabled: true,
keyboardShortcut: 'CommandOrControl+Shift+A',
});
vi.clearAllMocks();

const result = handler({} as Electron.IpcMainInvokeEvent, {
enabled: false,
keyboardShortcut: 'CommandOrControl+Shift+A',
});

expect(result).toEqual({ success: true });
expect(globalShortcut.unregister).toHaveBeenCalledWith(
'CommandOrControl+Shift+A',
);
expect(globalShortcut.register).not.toHaveBeenCalled();
});

it('unregisters previous shortcut when switching to a new one', () => {
const handler = getKeyboardShortcutHandler();

handler({} as Electron.IpcMainInvokeEvent, {
enabled: true,
keyboardShortcut: 'CommandOrControl+Shift+A',
});
vi.clearAllMocks();

handler({} as Electron.IpcMainInvokeEvent, {
enabled: true,
keyboardShortcut: 'CommandOrControl+Shift+B',
});

expect(globalShortcut.unregister).toHaveBeenCalledWith(
'CommandOrControl+Shift+A',
);
expect(globalShortcut.register).toHaveBeenCalledWith(
'CommandOrControl+Shift+B',
expect.any(Function),
);
});

it('returns success false and restores previous shortcut when new registration fails', () => {
const handler = getKeyboardShortcutHandler();

handler({} as Electron.IpcMainInvokeEvent, {
enabled: true,
keyboardShortcut: 'CommandOrControl+Shift+A',
});
vi.clearAllMocks();
vi.mocked(globalShortcut.register)
.mockReturnValueOnce(false)
.mockReturnValue(true);

const result = handler({} as Electron.IpcMainInvokeEvent, {
enabled: true,
keyboardShortcut: 'CommandOrControl+Shift+B',
});

expect(result).toEqual({ success: false });
expect(globalShortcut.register).toHaveBeenNthCalledWith(
1,
'CommandOrControl+Shift+B',
expect.any(Function),
);
expect(globalShortcut.register).toHaveBeenNthCalledWith(
2,
'CommandOrControl+Shift+A',
expect.any(Function),
);
});
});
});
50 changes: 38 additions & 12 deletions src/main/handlers/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,34 @@ import type { Menubar } from 'menubar';

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

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

/**
* Register IPC handlers for OS-level system operations.
*
* @param mb - The menubar instance used for show/hide on keyboard shortcut activation.
*/
export function registerSystemHandlers(mb: Menubar): void {
/**
* Currently registered accelerator for the global shortcut, or `null` when none.
*/
let lastRegisteredAccelerator: string | null = null;

const toggleWindow = () => {
if (mb.window.isVisible()) {
mb.hideWindow();
} else {
mb.showWindow();
}
};

/**
* Open the given URL in the user's default browser, with an option to activate the app.
*/
Expand All @@ -26,21 +41,32 @@ export function registerSystemHandlers(mb: Menubar): void {
/**
* Register or unregister a global keyboard shortcut that toggles the menubar window visibility.
*/
onMainEvent(
handleMainEvent(
EVENTS.UPDATE_KEYBOARD_SHORTCUT,
(_, { enabled, keyboardShortcut }: IKeyboardShortcut) => {
(_, data: EventData): IKeyboardShortcutResult => {
const { enabled, keyboardShortcut } = data as IKeyboardShortcut;
const previous = lastRegisteredAccelerator;

if (lastRegisteredAccelerator) {
globalShortcut.unregister(lastRegisteredAccelerator);
lastRegisteredAccelerator = null;
}

if (!enabled) {
globalShortcut.unregister(keyboardShortcut);
return;
return { success: true };
}

globalShortcut.register(keyboardShortcut, () => {
if (mb.window.isVisible()) {
mb.hideWindow();
} else {
mb.showWindow();
}
});
const ok = globalShortcut.register(keyboardShortcut, toggleWindow);
if (ok) {
lastRegisteredAccelerator = keyboardShortcut;
return { success: true };
}

if (previous) {
globalShortcut.register(previous, toggleWindow);
lastRegisteredAccelerator = previous;
}
return { success: false };
},
);

Expand Down
1 change: 1 addition & 0 deletions src/preload/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const logErrorMock = vi.fn();
vi.mock('./utils', () => ({
sendMainEvent: (...args: unknown[]) => sendMainEventMock(...args),
invokeMainEvent: (...args: unknown[]) => invokeMainEventMock(...args),
invokeMainEventWithData: (...args: unknown[]) => invokeMainEventMock(...args),
onRendererEvent: (...args: unknown[]) => onRendererEventMock(...args),
}));

Expand Down
28 changes: 18 additions & 10 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { contextBridge, webFrame } from 'electron';

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

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

/**
* The Gitify Bridge API exposed to the renderer via `contextBridge`.
Expand Down Expand Up @@ -56,16 +64,16 @@ export const api = {
}),

/**
* Register or unregister the global keyboard shortcut for toggling the app window.
* Apply the global keyboard shortcut for toggling the app window visibility.
*
* @param keyboardShortcut - `true` to register the shortcut, `false` to unregister.
* @param payload - Whether the shortcut is enabled and the Electron accelerator string.
* @returns Whether registration succeeded (when enabled).
*/
setKeyboardShortcut: (keyboardShortcut: boolean) => {
sendMainEvent(EVENTS.UPDATE_KEYBOARD_SHORTCUT, {
enabled: keyboardShortcut,
keyboardShortcut: APPLICATION.DEFAULT_KEYBOARD_SHORTCUT,
});
},
applyKeyboardShortcut: (payload: IKeyboardShortcut) =>
invokeMainEventWithData(
EVENTS.UPDATE_KEYBOARD_SHORTCUT,
payload,
) as Promise<IKeyboardShortcutResult>,

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

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

vi.mock('electron', () => {
type Listener = (event: unknown, ...args: unknown[]) => void;
Expand Down Expand Up @@ -49,6 +54,19 @@ describe('preload/utils', () => {
expect(result).toBe('response');
});

it('invokeMainEventWithData forwards structured payload and resolves', async () => {
const payload = { enabled: true, keyboardShortcut: 'CommandOrControl+G' };
const result = await invokeMainEventWithData(
EVENTS.UPDATE_KEYBOARD_SHORTCUT,
payload,
);
expect(ipcRenderer.invoke).toHaveBeenCalledWith(
EVENTS.UPDATE_KEYBOARD_SHORTCUT,
payload,
);
expect(result).toBe('response');
});

it('onRendererEvent registers listener and receives emitted data', () => {
const handlerMock = vi.fn();
onRendererEvent(
Expand Down
10 changes: 10 additions & 0 deletions src/preload/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ export function invokeMainEvent(
return ipcRenderer.invoke(event, data);
}

/**
* 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.
*
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/__helpers__/test-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ function AppContextProvider({
updateSetting: vi.fn(),
updateFilter: vi.fn(),

shortcutRegistrationError: null,
clearShortcutRegistrationError: vi.fn(),

...value,
} as TestAppContext;
}, [value]);
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/__helpers__/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ window.gitify = {
onResetApp: vi.fn(),
onSystemThemeUpdate: vi.fn(),
setAutoLaunch: vi.fn(),
setKeyboardShortcut: vi.fn(),
applyKeyboardShortcut: vi.fn().mockResolvedValue({ success: true }),
raiseNativeNotification: vi.fn(),
};

Expand Down
3 changes: 3 additions & 0 deletions src/renderer/__mocks__/state-mocks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { APPLICATION } from '../../shared/constants';

import { Constants } from '../constants';

import {
Expand Down Expand Up @@ -59,6 +61,7 @@ const mockTraySettings: TraySettingsState = {
const mockSystemSettings: SystemSettingsState = {
openLinks: OpenPreference.FOREGROUND,
keyboardShortcut: true,
openGitifyShortcut: APPLICATION.DEFAULT_KEYBOARD_SHORTCUT,
showNotifications: true,
playSound: true,
notificationVolume: 20 as Percentage,
Expand Down
Loading
Loading