Skip to content
Merged
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
39 changes: 39 additions & 0 deletions src/main/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Paths, WindowConfig } from './config';

vi.mock('./utils', () => ({
isDevMode: vi.fn().mockReturnValue(false),
}));

vi.mock('electron', () => ({
app: {
isPackaged: true,
},
}));

describe('main/config.ts', () => {
it('exports Paths object with expected properties', () => {
expect(Paths.preload).toBeDefined();
expect(Paths.preload).toContain('preload.js');

expect(Paths.indexHtml).toBeDefined();
expect(Paths.indexHtml).toContain('index.html');

expect(Paths.notificationSound).toBeDefined();
expect(Paths.notificationSound).toContain('.mp3');

expect(Paths.twemojiFolder).toBeDefined();
expect(Paths.twemojiFolder).toContain('twemoji');
});

it('exports WindowConfig with expected properties', () => {
expect(WindowConfig.width).toBe(500);
expect(WindowConfig.height).toBe(400);
expect(WindowConfig.minWidth).toBe(500);
expect(WindowConfig.minHeight).toBe(400);
expect(WindowConfig.resizable).toBe(false);
expect(WindowConfig.skipTaskbar).toBe(true);
expect(WindowConfig.webPreferences).toBeDefined();
expect(WindowConfig.webPreferences.contextIsolation).toBe(true);
expect(WindowConfig.webPreferences.nodeIntegration).toBe(false);
});
});
56 changes: 56 additions & 0 deletions src/main/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import path from 'node:path';
import { pathToFileURL } from 'node:url';

import type { BrowserWindowConstructorOptions } from 'electron';

import { APPLICATION } from '../shared/constants';

import { isDevMode } from './utils';

/**
* Resolved file-system and URL paths used throughout the main process.
*/
export const Paths = {
preload: path.resolve(__dirname, 'preload.js'),

get indexHtml(): string {
return isDevMode()
? process.env.VITE_DEV_SERVER_URL || ''
: pathToFileURL(path.resolve(__dirname, 'index.html')).href;
},

get notificationSound(): string {
return pathToFileURL(
path.resolve(
__dirname,
'assets',
'sounds',
APPLICATION.NOTIFICATION_SOUND,
),
).href;
},

get twemojiFolder(): string {
return pathToFileURL(path.resolve(__dirname, 'assets', 'images', 'twemoji'))
.href;
},
};

/**
* Default browser window construction options for the menubar popup.
*/
export const WindowConfig: BrowserWindowConstructorOptions = {
width: 500,
height: 400,
minWidth: 500,
minHeight: 400,
resizable: false,
skipTaskbar: true, // Hide the app from the Windows taskbar
webPreferences: {
preload: Paths.preload,
contextIsolation: true,
nodeIntegration: false,
// Disable web security in development to allow CORS requests
webSecurity: !process.env.VITE_DEV_SERVER_URL,
},
};
8 changes: 8 additions & 0 deletions src/main/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,40 +25,48 @@ 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,
);

expect(onMock).toHaveBeenCalledWith(EVENTS.WINDOW_SHOW, listenerMock);
});

it('handleMainEvent registers ipcMain.handle listener', () => {
const listenerMock = vi.fn();

handleMainEvent(
EVENTS.VERSION,
listenerMock as unknown as (
e: Electron.IpcMainInvokeEvent,
d: unknown,
) => void,
);

expect(handleMock).toHaveBeenCalledWith(EVENTS.VERSION, listenerMock);
});

it('sendRendererEvent forwards event to webContents with data', () => {
const sendMock = vi.fn();
const mb: MockMenubar = { window: { webContents: { send: sendMock } } };

sendRendererEvent(
mb as unknown as Menubar,
EVENTS.UPDATE_ICON_TITLE,
'title',
);

expect(sendMock).toHaveBeenCalledWith(EVENTS.UPDATE_ICON_TITLE, 'title');
});

it('sendRendererEvent forwards event without data', () => {
const sendMock = vi.fn();
const mb: MockMenubar = { window: { webContents: { send: sendMock } } };

sendRendererEvent(mb as unknown as Menubar, EVENTS.RESET_APP);

expect(sendMock).toHaveBeenCalledWith(EVENTS.RESET_APP, undefined);
});
});
25 changes: 15 additions & 10 deletions src/main/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import type { Menubar } from 'menubar';
import type { EventData, EventType } from '../shared/events';

/**
* Handle main event without expecting a response
* @param event
* @param listener
* 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,
Expand All @@ -16,9 +18,11 @@ export function onMainEvent(
}

/**
* Handle main event and return a response
* @param event
* @param listener
* 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.
*/
export function handleMainEvent(
event: EventType,
Expand All @@ -28,10 +32,11 @@ export function handleMainEvent(
}

/**
* Send main event to renderer
* @param mb the menubar instance
* @param event the type of event to send
* @param data the data to send with the event
* 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.
*/
export function sendRendererEvent(
mb: Menubar,
Expand Down
63 changes: 63 additions & 0 deletions src/main/handlers/app.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { Menubar } from 'menubar';

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

import { registerAppHandlers } from './app';

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

vi.mock('electron', () => ({
ipcMain: {
handle: (...args: unknown[]) => handleMock(...args),
on: (...args: unknown[]) => onMock(...args),
},
app: {
getVersion: vi.fn(() => '1.0.0'),
},
}));

vi.mock('../config', () => ({
Paths: {
notificationSound: 'file:///path/to/notification.mp3',
twemojiFolder: 'file:///path/to/twemoji',
},
}));

describe('main/handlers/app.ts', () => {
let menubar: Menubar;

beforeEach(() => {
vi.clearAllMocks();

menubar = {
showWindow: vi.fn(),
hideWindow: vi.fn(),
app: { quit: vi.fn() },
} as unknown as Menubar;
});

describe('registerAppHandlers', () => {
it('registers handlers without throwing', () => {
expect(() => registerAppHandlers(menubar)).not.toThrow();
});

it('registers expected app IPC event handlers', () => {
registerAppHandlers(menubar);

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

expect(registeredHandlers).toContain(EVENTS.VERSION);
expect(registeredHandlers).toContain(EVENTS.NOTIFICATION_SOUND_PATH);
expect(registeredHandlers).toContain(EVENTS.TWEMOJI_DIRECTORY);
expect(registeredEvents).toContain(EVENTS.WINDOW_SHOW);
expect(registeredEvents).toContain(EVENTS.WINDOW_HIDE);
expect(registeredEvents).toContain(EVENTS.QUIT);
});
});
});
31 changes: 31 additions & 0 deletions src/main/handlers/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { app } from 'electron';
import type { Menubar } from 'menubar';

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

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

/**
* Register IPC handlers for general application queries and window/app control.
*
* @param mb - The menubar instance used for window visibility and app quit control.
*/
export function registerAppHandlers(mb: Menubar): void {
handleMainEvent(EVENTS.VERSION, () => app.getVersion());

onMainEvent(EVENTS.WINDOW_SHOW, () => mb.showWindow());

onMainEvent(EVENTS.WINDOW_HIDE, () => mb.hideWindow());

onMainEvent(EVENTS.QUIT, () => mb.app.quit());

// Path handlers for renderer queries about resource locations
handleMainEvent(EVENTS.NOTIFICATION_SOUND_PATH, () => {
return Paths.notificationSound;
});

handleMainEvent(EVENTS.TWEMOJI_DIRECTORY, () => {
return Paths.twemojiFolder;
});
}
4 changes: 4 additions & 0 deletions src/main/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './app';
export * from './storage';
export * from './system';
export * from './tray';
43 changes: 43 additions & 0 deletions src/main/handlers/storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { EVENTS } from '../../shared/events';

import { registerStorageHandlers } from './storage';

const handleMock = vi.fn();

vi.mock('electron', () => ({
ipcMain: {
handle: (...args: unknown[]) => handleMock(...args),
},
safeStorage: {
encryptString: vi.fn((str: string) => Buffer.from(str)),
decryptString: vi.fn((buf: Buffer) => buf.toString()),
},
}));

const logErrorMock = vi.fn();
vi.mock('../../shared/logger', () => ({
logError: (...args: unknown[]) => logErrorMock(...args),
}));

describe('main/handlers/storage.ts', () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe('registerStorageHandlers', () => {
it('registers handlers without throwing', () => {
expect(() => registerStorageHandlers()).not.toThrow();
});

it('registers expected storage IPC event handlers', () => {
registerStorageHandlers();

const registeredHandlers = handleMock.mock.calls.map(
(call: [string]) => call[0],
);

expect(registeredHandlers).toContain(EVENTS.SAFE_STORAGE_ENCRYPT);
expect(registeredHandlers).toContain(EVENTS.SAFE_STORAGE_DECRYPT);
});
});
});
34 changes: 34 additions & 0 deletions src/main/handlers/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { safeStorage } from 'electron';

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

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

/**
* Register IPC handlers for OS-level safe storage operations.
*/
export function registerStorageHandlers(): void {
/**
* Encrypt a string using Electron's safeStorage and return the encrypted value as a base64 string.
*/
handleMainEvent(EVENTS.SAFE_STORAGE_ENCRYPT, (_, value: string) => {
return safeStorage.encryptString(value).toString('base64');
});

/**
* Decrypt a base64-encoded string using Electron's safeStorage and return the decrypted value.
*/
handleMainEvent(EVENTS.SAFE_STORAGE_DECRYPT, (_, value: string) => {
try {
return safeStorage.decryptString(Buffer.from(value, 'base64'));
} catch (err) {
logError(
'main:safe-storage-decrypt',
'Failed to decrypt value - data may be from old build',
err,
);
throw err;
}
});
}
Loading