Skip to content

Commit b7d3d97

Browse files
authored
refactor(main): organize main structure (#2678)
* refactor(main): organize main structure Signed-off-by: Adam Setch <adam.setch@outlook.com> * refactor(main): organize main structure Signed-off-by: Adam Setch <adam.setch@outlook.com> * refactor(main): organize main structure Signed-off-by: Adam Setch <adam.setch@outlook.com> * refactor(main): organize main structure Signed-off-by: Adam Setch <adam.setch@outlook.com> * refactor(main): organize main structure Signed-off-by: Adam Setch <adam.setch@outlook.com> --------- Signed-off-by: Adam Setch <adam.setch@outlook.com>
1 parent 065c8c9 commit b7d3d97

29 files changed

Lines changed: 1249 additions & 309 deletions

src/main/config.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Paths, WindowConfig } from './config';
2+
3+
vi.mock('./utils', () => ({
4+
isDevMode: vi.fn().mockReturnValue(false),
5+
}));
6+
7+
vi.mock('electron', () => ({
8+
app: {
9+
isPackaged: true,
10+
},
11+
}));
12+
13+
describe('main/config.ts', () => {
14+
it('exports Paths object with expected properties', () => {
15+
expect(Paths.preload).toBeDefined();
16+
expect(Paths.preload).toContain('preload.js');
17+
18+
expect(Paths.indexHtml).toBeDefined();
19+
expect(Paths.indexHtml).toContain('index.html');
20+
21+
expect(Paths.notificationSound).toBeDefined();
22+
expect(Paths.notificationSound).toContain('.mp3');
23+
24+
expect(Paths.twemojiFolder).toBeDefined();
25+
expect(Paths.twemojiFolder).toContain('twemoji');
26+
});
27+
28+
it('exports WindowConfig with expected properties', () => {
29+
expect(WindowConfig.width).toBe(500);
30+
expect(WindowConfig.height).toBe(400);
31+
expect(WindowConfig.minWidth).toBe(500);
32+
expect(WindowConfig.minHeight).toBe(400);
33+
expect(WindowConfig.resizable).toBe(false);
34+
expect(WindowConfig.skipTaskbar).toBe(true);
35+
expect(WindowConfig.webPreferences).toBeDefined();
36+
expect(WindowConfig.webPreferences.contextIsolation).toBe(true);
37+
expect(WindowConfig.webPreferences.nodeIntegration).toBe(false);
38+
});
39+
});

src/main/config.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import path from 'node:path';
2+
import { pathToFileURL } from 'node:url';
3+
4+
import type { BrowserWindowConstructorOptions } from 'electron';
5+
6+
import { APPLICATION } from '../shared/constants';
7+
8+
import { isDevMode } from './utils';
9+
10+
/**
11+
* Resolved file-system and URL paths used throughout the main process.
12+
*/
13+
export const Paths = {
14+
preload: path.resolve(__dirname, 'preload.js'),
15+
16+
get indexHtml(): string {
17+
return isDevMode()
18+
? process.env.VITE_DEV_SERVER_URL || ''
19+
: pathToFileURL(path.resolve(__dirname, 'index.html')).href;
20+
},
21+
22+
get notificationSound(): string {
23+
return pathToFileURL(
24+
path.resolve(
25+
__dirname,
26+
'assets',
27+
'sounds',
28+
APPLICATION.NOTIFICATION_SOUND,
29+
),
30+
).href;
31+
},
32+
33+
get twemojiFolder(): string {
34+
return pathToFileURL(path.resolve(__dirname, 'assets', 'images', 'twemoji'))
35+
.href;
36+
},
37+
};
38+
39+
/**
40+
* Default browser window construction options for the menubar popup.
41+
*/
42+
export const WindowConfig: BrowserWindowConstructorOptions = {
43+
width: 500,
44+
height: 400,
45+
minWidth: 500,
46+
minHeight: 400,
47+
resizable: false,
48+
skipTaskbar: true, // Hide the app from the Windows taskbar
49+
webPreferences: {
50+
preload: Paths.preload,
51+
contextIsolation: true,
52+
nodeIntegration: false,
53+
// Disable web security in development to allow CORS requests
54+
webSecurity: !process.env.VITE_DEV_SERVER_URL,
55+
},
56+
};

src/main/events.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,40 +25,48 @@ describe('main/events', () => {
2525

2626
it('onMainEvent registers ipcMain.on listener', () => {
2727
const listenerMock = vi.fn();
28+
2829
onMainEvent(
2930
EVENTS.WINDOW_SHOW,
3031
listenerMock as unknown as (e: Electron.IpcMainEvent, d: unknown) => void,
3132
);
33+
3234
expect(onMock).toHaveBeenCalledWith(EVENTS.WINDOW_SHOW, listenerMock);
3335
});
3436

3537
it('handleMainEvent registers ipcMain.handle listener', () => {
3638
const listenerMock = vi.fn();
39+
3740
handleMainEvent(
3841
EVENTS.VERSION,
3942
listenerMock as unknown as (
4043
e: Electron.IpcMainInvokeEvent,
4144
d: unknown,
4245
) => void,
4346
);
47+
4448
expect(handleMock).toHaveBeenCalledWith(EVENTS.VERSION, listenerMock);
4549
});
4650

4751
it('sendRendererEvent forwards event to webContents with data', () => {
4852
const sendMock = vi.fn();
4953
const mb: MockMenubar = { window: { webContents: { send: sendMock } } };
54+
5055
sendRendererEvent(
5156
mb as unknown as Menubar,
5257
EVENTS.UPDATE_ICON_TITLE,
5358
'title',
5459
);
60+
5561
expect(sendMock).toHaveBeenCalledWith(EVENTS.UPDATE_ICON_TITLE, 'title');
5662
});
5763

5864
it('sendRendererEvent forwards event without data', () => {
5965
const sendMock = vi.fn();
6066
const mb: MockMenubar = { window: { webContents: { send: sendMock } } };
67+
6168
sendRendererEvent(mb as unknown as Menubar, EVENTS.RESET_APP);
69+
6270
expect(sendMock).toHaveBeenCalledWith(EVENTS.RESET_APP, undefined);
6371
});
6472
});

src/main/events.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import type { Menubar } from 'menubar';
44
import type { EventData, EventType } from '../shared/events';
55

66
/**
7-
* Handle main event without expecting a response
8-
* @param event
9-
* @param listener
7+
* Register a fire-and-forget IPC listener on the main process (ipcMain.on).
8+
* Use this when the renderer sends a one-way message and no return value is needed.
9+
*
10+
* @param event - The IPC channel/event name to listen on.
11+
* @param listener - Callback invoked when the event is received.
1012
*/
1113
export function onMainEvent(
1214
event: EventType,
@@ -16,9 +18,11 @@ export function onMainEvent(
1618
}
1719

1820
/**
19-
* Handle main event and return a response
20-
* @param event
21-
* @param listener
21+
* Register a request/response IPC handler on the main process (ipcMain.handle).
22+
* Use this when the renderer invokes a channel and expects a value back.
23+
*
24+
* @param event - The IPC channel/event name to handle.
25+
* @param listener - Callback whose return value is sent back to the renderer.
2226
*/
2327
export function handleMainEvent(
2428
event: EventType,
@@ -28,10 +32,11 @@ export function handleMainEvent(
2832
}
2933

3034
/**
31-
* Send main event to renderer
32-
* @param mb the menubar instance
33-
* @param event the type of event to send
34-
* @param data the data to send with the event
35+
* Push an event from the main process to the renderer via webContents.
36+
*
37+
* @param mb - The menubar instance whose window receives the event.
38+
* @param event - The IPC channel/event name to emit.
39+
* @param data - Optional payload sent with the event.
3540
*/
3641
export function sendRendererEvent(
3742
mb: Menubar,

src/main/handlers/app.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { Menubar } from 'menubar';
2+
3+
import { EVENTS } from '../../shared/events';
4+
5+
import { registerAppHandlers } from './app';
6+
7+
const handleMock = vi.fn();
8+
const onMock = vi.fn();
9+
10+
vi.mock('electron', () => ({
11+
ipcMain: {
12+
handle: (...args: unknown[]) => handleMock(...args),
13+
on: (...args: unknown[]) => onMock(...args),
14+
},
15+
app: {
16+
getVersion: vi.fn(() => '1.0.0'),
17+
},
18+
}));
19+
20+
vi.mock('../config', () => ({
21+
Paths: {
22+
notificationSound: 'file:///path/to/notification.mp3',
23+
twemojiFolder: 'file:///path/to/twemoji',
24+
},
25+
}));
26+
27+
describe('main/handlers/app.ts', () => {
28+
let menubar: Menubar;
29+
30+
beforeEach(() => {
31+
vi.clearAllMocks();
32+
33+
menubar = {
34+
showWindow: vi.fn(),
35+
hideWindow: vi.fn(),
36+
app: { quit: vi.fn() },
37+
} as unknown as Menubar;
38+
});
39+
40+
describe('registerAppHandlers', () => {
41+
it('registers handlers without throwing', () => {
42+
expect(() => registerAppHandlers(menubar)).not.toThrow();
43+
});
44+
45+
it('registers expected app IPC event handlers', () => {
46+
registerAppHandlers(menubar);
47+
48+
const registeredHandlers = handleMock.mock.calls.map(
49+
(call: [string]) => call[0],
50+
);
51+
const registeredEvents = onMock.mock.calls.map(
52+
(call: [string]) => call[0],
53+
);
54+
55+
expect(registeredHandlers).toContain(EVENTS.VERSION);
56+
expect(registeredHandlers).toContain(EVENTS.NOTIFICATION_SOUND_PATH);
57+
expect(registeredHandlers).toContain(EVENTS.TWEMOJI_DIRECTORY);
58+
expect(registeredEvents).toContain(EVENTS.WINDOW_SHOW);
59+
expect(registeredEvents).toContain(EVENTS.WINDOW_HIDE);
60+
expect(registeredEvents).toContain(EVENTS.QUIT);
61+
});
62+
});
63+
});

src/main/handlers/app.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { app } from 'electron';
2+
import type { Menubar } from 'menubar';
3+
4+
import { EVENTS } from '../../shared/events';
5+
6+
import { Paths } from '../config';
7+
import { handleMainEvent, onMainEvent } from '../events';
8+
9+
/**
10+
* Register IPC handlers for general application queries and window/app control.
11+
*
12+
* @param mb - The menubar instance used for window visibility and app quit control.
13+
*/
14+
export function registerAppHandlers(mb: Menubar): void {
15+
handleMainEvent(EVENTS.VERSION, () => app.getVersion());
16+
17+
onMainEvent(EVENTS.WINDOW_SHOW, () => mb.showWindow());
18+
19+
onMainEvent(EVENTS.WINDOW_HIDE, () => mb.hideWindow());
20+
21+
onMainEvent(EVENTS.QUIT, () => mb.app.quit());
22+
23+
// Path handlers for renderer queries about resource locations
24+
handleMainEvent(EVENTS.NOTIFICATION_SOUND_PATH, () => {
25+
return Paths.notificationSound;
26+
});
27+
28+
handleMainEvent(EVENTS.TWEMOJI_DIRECTORY, () => {
29+
return Paths.twemojiFolder;
30+
});
31+
}

src/main/handlers/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './app';
2+
export * from './storage';
3+
export * from './system';
4+
export * from './tray';

src/main/handlers/storage.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { EVENTS } from '../../shared/events';
2+
3+
import { registerStorageHandlers } from './storage';
4+
5+
const handleMock = vi.fn();
6+
7+
vi.mock('electron', () => ({
8+
ipcMain: {
9+
handle: (...args: unknown[]) => handleMock(...args),
10+
},
11+
safeStorage: {
12+
encryptString: vi.fn((str: string) => Buffer.from(str)),
13+
decryptString: vi.fn((buf: Buffer) => buf.toString()),
14+
},
15+
}));
16+
17+
const logErrorMock = vi.fn();
18+
vi.mock('../../shared/logger', () => ({
19+
logError: (...args: unknown[]) => logErrorMock(...args),
20+
}));
21+
22+
describe('main/handlers/storage.ts', () => {
23+
beforeEach(() => {
24+
vi.clearAllMocks();
25+
});
26+
27+
describe('registerStorageHandlers', () => {
28+
it('registers handlers without throwing', () => {
29+
expect(() => registerStorageHandlers()).not.toThrow();
30+
});
31+
32+
it('registers expected storage IPC event handlers', () => {
33+
registerStorageHandlers();
34+
35+
const registeredHandlers = handleMock.mock.calls.map(
36+
(call: [string]) => call[0],
37+
);
38+
39+
expect(registeredHandlers).toContain(EVENTS.SAFE_STORAGE_ENCRYPT);
40+
expect(registeredHandlers).toContain(EVENTS.SAFE_STORAGE_DECRYPT);
41+
});
42+
});
43+
});

src/main/handlers/storage.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { safeStorage } from 'electron';
2+
3+
import { EVENTS } from '../../shared/events';
4+
import { logError } from '../../shared/logger';
5+
6+
import { handleMainEvent } from '../events';
7+
8+
/**
9+
* Register IPC handlers for OS-level safe storage operations.
10+
*/
11+
export function registerStorageHandlers(): void {
12+
/**
13+
* Encrypt a string using Electron's safeStorage and return the encrypted value as a base64 string.
14+
*/
15+
handleMainEvent(EVENTS.SAFE_STORAGE_ENCRYPT, (_, value: string) => {
16+
return safeStorage.encryptString(value).toString('base64');
17+
});
18+
19+
/**
20+
* Decrypt a base64-encoded string using Electron's safeStorage and return the decrypted value.
21+
*/
22+
handleMainEvent(EVENTS.SAFE_STORAGE_DECRYPT, (_, value: string) => {
23+
try {
24+
return safeStorage.decryptString(Buffer.from(value, 'base64'));
25+
} catch (err) {
26+
logError(
27+
'main:safe-storage-decrypt',
28+
'Failed to decrypt value - data may be from old build',
29+
err,
30+
);
31+
throw err;
32+
}
33+
});
34+
}

0 commit comments

Comments
 (0)