refactor(main): organize main structure by setchy · Pull Request #2678 · gitify-app/gitify

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;
}
});
}