Skip to content

Commit 147ab81

Browse files
committed
feat(settings): customizable global keyboard shortcut
1 parent 658c010 commit 147ab81

File tree

21 files changed

+742
-52
lines changed

21 files changed

+742
-52
lines changed

src/main/events.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ export function onMainEvent(
2626
*/
2727
export function handleMainEvent(
2828
event: EventType,
29-
listener: (event: Electron.IpcMainInvokeEvent, data: EventData) => void,
29+
listener: (
30+
event: Electron.IpcMainInvokeEvent,
31+
data: EventData,
32+
) => unknown | Promise<unknown>,
3033
) {
3134
ipcMain.handle(event, listener);
3235
}

src/main/handlers/system.test.ts

Lines changed: 116 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1+
import { globalShortcut } from 'electron';
12
import type { Menubar } from 'menubar';
23

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

56
import { registerSystemHandlers } from './system';
67

78
const onMock = vi.fn();
9+
const handleMock = vi.fn();
810

911
vi.mock('electron', () => ({
1012
ipcMain: {
1113
on: (...args: unknown[]) => onMock(...args),
14+
handle: (...args: unknown[]) => handleMock(...args),
1215
},
1316
globalShortcut: {
1417
register: vi.fn(),
@@ -26,6 +29,9 @@ describe('main/handlers/system.ts', () => {
2629
let menubar: Menubar;
2730

2831
beforeEach(() => {
32+
vi.clearAllMocks();
33+
vi.mocked(globalShortcut.register).mockReturnValue(true);
34+
2935
menubar = {
3036
showWindow: vi.fn(),
3137
hideWindow: vi.fn(),
@@ -35,6 +41,20 @@ describe('main/handlers/system.ts', () => {
3541
} as unknown as Menubar;
3642
});
3743

44+
function getKeyboardShortcutHandler() {
45+
registerSystemHandlers(menubar);
46+
const handleCall = handleMock.mock.calls.find(
47+
(c) => c[0] === EVENTS.UPDATE_KEYBOARD_SHORTCUT,
48+
);
49+
if (!handleCall) {
50+
throw new Error('UPDATE_KEYBOARD_SHORTCUT handler not registered');
51+
}
52+
return handleCall[1] as (
53+
event: Electron.IpcMainInvokeEvent,
54+
data: { enabled: boolean; keyboardShortcut: string },
55+
) => { success: boolean };
56+
}
57+
3858
describe('registerSystemHandlers', () => {
3959
it('registers handlers without throwing', () => {
4060
expect(() => registerSystemHandlers(menubar)).not.toThrow();
@@ -43,13 +63,105 @@ describe('main/handlers/system.ts', () => {
4363
it('registers expected system IPC event handlers', () => {
4464
registerSystemHandlers(menubar);
4565

46-
const registeredEvents = onMock.mock.calls.map(
66+
const onEvents = onMock.mock.calls.map((call: [string]) => call[0]);
67+
const handleEvents = handleMock.mock.calls.map(
4768
(call: [string]) => call[0],
4869
);
4970

50-
expect(registeredEvents).toContain(EVENTS.OPEN_EXTERNAL);
51-
expect(registeredEvents).toContain(EVENTS.UPDATE_KEYBOARD_SHORTCUT);
52-
expect(registeredEvents).toContain(EVENTS.UPDATE_AUTO_LAUNCH);
71+
expect(onEvents).toContain(EVENTS.OPEN_EXTERNAL);
72+
expect(onEvents).toContain(EVENTS.UPDATE_AUTO_LAUNCH);
73+
expect(handleEvents).toContain(EVENTS.UPDATE_KEYBOARD_SHORTCUT);
74+
});
75+
});
76+
77+
describe('UPDATE_KEYBOARD_SHORTCUT', () => {
78+
it('registers shortcut when enabled', () => {
79+
const handler = getKeyboardShortcutHandler();
80+
81+
const result = handler({} as Electron.IpcMainInvokeEvent, {
82+
enabled: true,
83+
keyboardShortcut: 'CommandOrControl+Shift+G',
84+
});
85+
86+
expect(result).toEqual({ success: true });
87+
expect(globalShortcut.register).toHaveBeenCalledWith(
88+
'CommandOrControl+Shift+G',
89+
expect.any(Function),
90+
);
91+
});
92+
93+
it('unregisters when disabled after being enabled', () => {
94+
const handler = getKeyboardShortcutHandler();
95+
96+
handler({} as Electron.IpcMainInvokeEvent, {
97+
enabled: true,
98+
keyboardShortcut: 'CommandOrControl+Shift+A',
99+
});
100+
vi.clearAllMocks();
101+
102+
const result = handler({} as Electron.IpcMainInvokeEvent, {
103+
enabled: false,
104+
keyboardShortcut: 'CommandOrControl+Shift+A',
105+
});
106+
107+
expect(result).toEqual({ success: true });
108+
expect(globalShortcut.unregister).toHaveBeenCalledWith(
109+
'CommandOrControl+Shift+A',
110+
);
111+
expect(globalShortcut.register).not.toHaveBeenCalled();
112+
});
113+
114+
it('unregisters previous shortcut when switching to a new one', () => {
115+
const handler = getKeyboardShortcutHandler();
116+
117+
handler({} as Electron.IpcMainInvokeEvent, {
118+
enabled: true,
119+
keyboardShortcut: 'CommandOrControl+Shift+A',
120+
});
121+
vi.clearAllMocks();
122+
123+
handler({} as Electron.IpcMainInvokeEvent, {
124+
enabled: true,
125+
keyboardShortcut: 'CommandOrControl+Shift+B',
126+
});
127+
128+
expect(globalShortcut.unregister).toHaveBeenCalledWith(
129+
'CommandOrControl+Shift+A',
130+
);
131+
expect(globalShortcut.register).toHaveBeenCalledWith(
132+
'CommandOrControl+Shift+B',
133+
expect.any(Function),
134+
);
135+
});
136+
137+
it('returns success false and restores previous shortcut when new registration fails', () => {
138+
const handler = getKeyboardShortcutHandler();
139+
140+
handler({} as Electron.IpcMainInvokeEvent, {
141+
enabled: true,
142+
keyboardShortcut: 'CommandOrControl+Shift+A',
143+
});
144+
vi.clearAllMocks();
145+
vi.mocked(globalShortcut.register)
146+
.mockReturnValueOnce(false)
147+
.mockReturnValue(true);
148+
149+
const result = handler({} as Electron.IpcMainInvokeEvent, {
150+
enabled: true,
151+
keyboardShortcut: 'CommandOrControl+Shift+B',
152+
});
153+
154+
expect(result).toEqual({ success: false });
155+
expect(globalShortcut.register).toHaveBeenNthCalledWith(
156+
1,
157+
'CommandOrControl+Shift+B',
158+
expect.any(Function),
159+
);
160+
expect(globalShortcut.register).toHaveBeenNthCalledWith(
161+
2,
162+
'CommandOrControl+Shift+A',
163+
expect.any(Function),
164+
);
53165
});
54166
});
55167
});

src/main/handlers/system.ts

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,34 @@ import type { Menubar } from 'menubar';
33

44
import {
55
EVENTS,
6+
type EventData,
67
type IAutoLaunch,
78
type IKeyboardShortcut,
9+
type IKeyboardShortcutResult,
810
type IOpenExternal,
911
} from '../../shared/events';
1012

11-
import { onMainEvent } from '../events';
13+
import { handleMainEvent, onMainEvent } from '../events';
1214

1315
/**
1416
* Register IPC handlers for OS-level system operations.
1517
*
1618
* @param mb - The menubar instance used for show/hide on keyboard shortcut activation.
1719
*/
1820
export function registerSystemHandlers(mb: Menubar): void {
21+
/**
22+
* Currently registered accelerator for the global shortcut, or `null` when none.
23+
*/
24+
let lastRegisteredAccelerator: string | null = null;
25+
26+
const toggleWindow = () => {
27+
if (mb.window.isVisible()) {
28+
mb.hideWindow();
29+
} else {
30+
mb.showWindow();
31+
}
32+
};
33+
1934
/**
2035
* Open the given URL in the user's default browser, with an option to activate the app.
2136
*/
@@ -26,21 +41,32 @@ export function registerSystemHandlers(mb: Menubar): void {
2641
/**
2742
* Register or unregister a global keyboard shortcut that toggles the menubar window visibility.
2843
*/
29-
onMainEvent(
44+
handleMainEvent(
3045
EVENTS.UPDATE_KEYBOARD_SHORTCUT,
31-
(_, { enabled, keyboardShortcut }: IKeyboardShortcut) => {
46+
(_, data: EventData): IKeyboardShortcutResult => {
47+
const { enabled, keyboardShortcut } = data as IKeyboardShortcut;
48+
const previous = lastRegisteredAccelerator;
49+
50+
if (lastRegisteredAccelerator) {
51+
globalShortcut.unregister(lastRegisteredAccelerator);
52+
lastRegisteredAccelerator = null;
53+
}
54+
3255
if (!enabled) {
33-
globalShortcut.unregister(keyboardShortcut);
34-
return;
56+
return { success: true };
3557
}
3658

37-
globalShortcut.register(keyboardShortcut, () => {
38-
if (mb.window.isVisible()) {
39-
mb.hideWindow();
40-
} else {
41-
mb.showWindow();
42-
}
43-
});
59+
const ok = globalShortcut.register(keyboardShortcut, toggleWindow);
60+
if (ok) {
61+
lastRegisteredAccelerator = keyboardShortcut;
62+
return { success: true };
63+
}
64+
65+
if (previous) {
66+
globalShortcut.register(previous, toggleWindow);
67+
lastRegisteredAccelerator = previous;
68+
}
69+
return { success: false };
4470
},
4571
);
4672

src/preload/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const {
2424
vi.mock('./utils', () => ({
2525
sendMainEvent: (...args: unknown[]) => sendMainEventMock(...args),
2626
invokeMainEvent: (...args: unknown[]) => invokeMainEventMock(...args),
27+
invokeMainEventWithData: (...args: unknown[]) => invokeMainEventMock(...args),
2728
onRendererEvent: (...args: unknown[]) => onRendererEventMock(...args),
2829
}));
2930

src/preload/index.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import { contextBridge, webFrame } from 'electron';
22

3-
import { APPLICATION } from '../shared/constants';
3+
import type {
4+
IKeyboardShortcut,
5+
IKeyboardShortcutResult,
6+
} from '../shared/events';
47
import { EVENTS } from '../shared/events';
58
import { isLinux, isMacOS, isWindows } from '../shared/platform';
69

7-
import { invokeMainEvent, onRendererEvent, sendMainEvent } from './utils';
10+
import {
11+
invokeMainEvent,
12+
invokeMainEventWithData,
13+
onRendererEvent,
14+
sendMainEvent,
15+
} from './utils';
816

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

5866
/**
59-
* Register or unregister the global keyboard shortcut for toggling the app window.
67+
* Apply the global keyboard shortcut for toggling the app window visibility.
6068
*
61-
* @param keyboardShortcut - `true` to register the shortcut, `false` to unregister.
69+
* @param payload - Whether the shortcut is enabled and the Electron accelerator string.
70+
* @returns Whether registration succeeded (when enabled).
6271
*/
63-
setKeyboardShortcut: (keyboardShortcut: boolean) => {
64-
sendMainEvent(EVENTS.UPDATE_KEYBOARD_SHORTCUT, {
65-
enabled: keyboardShortcut,
66-
keyboardShortcut: APPLICATION.DEFAULT_KEYBOARD_SHORTCUT,
67-
});
68-
},
72+
applyKeyboardShortcut: (payload: IKeyboardShortcut) =>
73+
invokeMainEventWithData(
74+
EVENTS.UPDATE_KEYBOARD_SHORTCUT,
75+
payload,
76+
) as Promise<IKeyboardShortcutResult>,
6977

7078
/** Tray icon controls. */
7179
tray: {

src/preload/utils.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { EVENTS } from '../shared/events';
22

3-
import { invokeMainEvent, onRendererEvent, sendMainEvent } from './utils';
3+
import {
4+
invokeMainEvent,
5+
invokeMainEventWithData,
6+
onRendererEvent,
7+
sendMainEvent,
8+
} from './utils';
49

510
vi.mock('electron', () => {
611
type Listener = (event: unknown, ...args: unknown[]) => void;
@@ -45,6 +50,19 @@ describe('preload/utils', () => {
4550
expect(result).toBe('response');
4651
});
4752

53+
it('invokeMainEventWithData forwards structured payload and resolves', async () => {
54+
const payload = { enabled: true, keyboardShortcut: 'CommandOrControl+G' };
55+
const result = await invokeMainEventWithData(
56+
EVENTS.UPDATE_KEYBOARD_SHORTCUT,
57+
payload,
58+
);
59+
expect(ipcRenderer.invoke).toHaveBeenCalledWith(
60+
EVENTS.UPDATE_KEYBOARD_SHORTCUT,
61+
payload,
62+
);
63+
expect(result).toBe('response');
64+
});
65+
4866
it('onRendererEvent registers listener and receives emitted data', () => {
4967
const handlerMock = vi.fn();
5068
onRendererEvent(

src/preload/utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ export async function invokeMainEvent(
3232
}
3333
}
3434

35+
/**
36+
* Invoke a main-process handler with structured `EventData` and await the result.
37+
*/
38+
export function invokeMainEventWithData(
39+
event: EventType,
40+
data?: EventData,
41+
): Promise<unknown> {
42+
return ipcRenderer.invoke(event, data);
43+
}
44+
3545
/**
3646
* Register a listener for an IPC event sent from the main process to the renderer.
3747
*

src/renderer/__helpers__/test-utils.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ function AppContextProvider({
7777
updateSetting: vi.fn(),
7878
updateFilter: vi.fn(),
7979

80+
shortcutRegistrationError: null,
81+
clearShortcutRegistrationError: vi.fn(),
82+
8083
...value,
8184
} as TestAppContext;
8285
}, [value]);

src/renderer/__helpers__/vitest.setup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ window.gitify = {
6161
onResetApp: vi.fn(),
6262
onSystemThemeUpdate: vi.fn(),
6363
setAutoLaunch: vi.fn(),
64-
setKeyboardShortcut: vi.fn(),
64+
applyKeyboardShortcut: vi.fn().mockResolvedValue({ success: true }),
6565
raiseNativeNotification: vi.fn(),
6666
};
6767

src/renderer/__mocks__/state-mocks.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { APPLICATION } from '../../shared/constants';
2+
13
import { Constants } from '../constants';
24

35
import {
@@ -59,6 +61,7 @@ const mockTraySettings: TraySettingsState = {
5961
const mockSystemSettings: SystemSettingsState = {
6062
openLinks: OpenPreference.FOREGROUND,
6163
keyboardShortcut: true,
64+
openGitifyShortcut: APPLICATION.DEFAULT_KEYBOARD_SHORTCUT,
6265
showNotifications: true,
6366
playSound: true,
6467
notificationVolume: 20 as Percentage,

0 commit comments

Comments
 (0)