Skip to content

Commit 2ae9b2c

Browse files
committed
refactor(main): organize main structure
Signed-off-by: Adam Setch <adam.setch@outlook.com>
1 parent 065c8c9 commit 2ae9b2c

23 files changed

Lines changed: 1061 additions & 290 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/handlers/app.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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.wav',
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+
it('registers handlers without throwing', () => {
41+
expect(() => registerAppHandlers(menubar)).not.toThrow();
42+
});
43+
44+
it('registers VERSION, NOTIFICATION_SOUND_PATH, TWEMOJI_DIRECTORY, WINDOW_SHOW, WINDOW_HIDE, and QUIT handlers', () => {
45+
registerAppHandlers(menubar);
46+
47+
const registeredHandlers = handleMock.mock.calls.map(
48+
(call: [string]) => call[0],
49+
);
50+
const registeredEvents = onMock.mock.calls.map((call: [string]) => call[0]);
51+
52+
expect(registeredHandlers).toContain(EVENTS.VERSION);
53+
expect(registeredHandlers).toContain(EVENTS.NOTIFICATION_SOUND_PATH);
54+
expect(registeredHandlers).toContain(EVENTS.TWEMOJI_DIRECTORY);
55+
expect(registeredEvents).toContain(EVENTS.WINDOW_SHOW);
56+
expect(registeredEvents).toContain(EVENTS.WINDOW_HIDE);
57+
expect(registeredEvents).toContain(EVENTS.QUIT);
58+
});
59+
});

src/main/handlers/app.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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+
handleMainEvent(EVENTS.NOTIFICATION_SOUND_PATH, () => {
24+
return Paths.notificationSound;
25+
});
26+
27+
handleMainEvent(EVENTS.TWEMOJI_DIRECTORY, () => {
28+
return Paths.twemojiFolder;
29+
});
30+
}

src/main/handlers/storage.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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+
it('registers handlers without throwing', () => {
28+
expect(() => registerStorageHandlers()).not.toThrow();
29+
});
30+
31+
it('registers SAFE_STORAGE_ENCRYPT and SAFE_STORAGE_DECRYPT handlers', () => {
32+
registerStorageHandlers();
33+
34+
const registeredHandlers = handleMock.mock.calls.map(
35+
(call: [string]) => call[0],
36+
);
37+
38+
expect(registeredHandlers).toContain(EVENTS.SAFE_STORAGE_ENCRYPT);
39+
expect(registeredHandlers).toContain(EVENTS.SAFE_STORAGE_DECRYPT);
40+
});
41+
});

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+
}

src/main/handlers/system.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { Menubar } from 'menubar';
2+
3+
import { EVENTS } from '../../shared/events';
4+
5+
import { registerSystemHandlers } from './system';
6+
7+
const onMock = vi.fn();
8+
9+
vi.mock('electron', () => ({
10+
ipcMain: {
11+
on: (...args: unknown[]) => onMock(...args),
12+
},
13+
globalShortcut: {
14+
register: vi.fn(),
15+
unregister: vi.fn(),
16+
},
17+
app: {
18+
setLoginItemSettings: vi.fn(),
19+
},
20+
shell: {
21+
openExternal: vi.fn(),
22+
},
23+
}));
24+
25+
describe('main/handlers/system.ts', () => {
26+
let menubar: Menubar;
27+
28+
beforeEach(() => {
29+
vi.clearAllMocks();
30+
31+
menubar = {
32+
showWindow: vi.fn(),
33+
hideWindow: vi.fn(),
34+
window: {
35+
isVisible: vi.fn().mockReturnValue(false),
36+
},
37+
} as unknown as Menubar;
38+
});
39+
40+
it('registers handlers without throwing', () => {
41+
expect(() => registerSystemHandlers(menubar)).not.toThrow();
42+
});
43+
44+
it('registers expected IPC event handlers', () => {
45+
registerSystemHandlers(menubar);
46+
47+
const registeredEvents = onMock.mock.calls.map((call: [string]) => call[0]);
48+
49+
expect(registeredEvents).toContain(EVENTS.OPEN_EXTERNAL);
50+
expect(registeredEvents).toContain(EVENTS.UPDATE_KEYBOARD_SHORTCUT);
51+
expect(registeredEvents).toContain(EVENTS.UPDATE_AUTO_LAUNCH);
52+
});
53+
});

src/main/handlers/system.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { app, globalShortcut, shell } from 'electron';
2+
import type { Menubar } from 'menubar';
3+
4+
import {
5+
EVENTS,
6+
type IAutoLaunch,
7+
type IKeyboardShortcut,
8+
type IOpenExternal,
9+
} from '../../shared/events';
10+
11+
import { onMainEvent } from '../events';
12+
13+
/**
14+
* Register IPC handlers for OS-level system operations.
15+
*
16+
* @param mb - The menubar instance used for show/hide on keyboard shortcut activation.
17+
*/
18+
export function registerSystemHandlers(mb: Menubar): void {
19+
/**
20+
* Open the given URL in the user's default browser, with an option to activate the app.
21+
*/
22+
onMainEvent(EVENTS.OPEN_EXTERNAL, (_, { url, activate }: IOpenExternal) =>
23+
shell.openExternal(url, { activate }),
24+
);
25+
26+
/**
27+
* Register or unregister a global keyboard shortcut that toggles the menubar window visibility.
28+
*/
29+
onMainEvent(
30+
EVENTS.UPDATE_KEYBOARD_SHORTCUT,
31+
(_, { enabled, keyboardShortcut }: IKeyboardShortcut) => {
32+
if (!enabled) {
33+
globalShortcut.unregister(keyboardShortcut);
34+
return;
35+
}
36+
37+
globalShortcut.register(keyboardShortcut, () => {
38+
if (mb.window.isVisible()) {
39+
mb.hideWindow();
40+
} else {
41+
mb.showWindow();
42+
}
43+
});
44+
},
45+
);
46+
47+
/**
48+
* Update the application's auto-launch setting based on the provided configuration.
49+
*/
50+
onMainEvent(EVENTS.UPDATE_AUTO_LAUNCH, (_, settings: IAutoLaunch) => {
51+
app.setLoginItemSettings(settings);
52+
});
53+
}

0 commit comments

Comments
 (0)