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
35 changes: 33 additions & 2 deletions app/interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function hidePlayTimeSummary() {
* @param {string} gameId - The ID of the game to load.
* @param {HTMLElement} gameContainer - The element that will receive the game HTML.
* @param {HTMLElement} announcer - Aria-live element for accessibility announcements.
* @returns {Promise<object>} The initialized game plugin module default export.
*/
async function loadAndInitGame(gameId, gameContainer, announcer) {
const result = await window.api.invoke('games:load', gameId);
Expand All @@ -94,6 +95,7 @@ async function loadAndInitGame(gameId, gameContainer, announcer) {
announcer.textContent = `${result.manifest.name} loaded. Get ready to play!`;
const mod = await import(`./games/${gameId}/${result.manifest.entryPoint}`);
mod.default.init(gameContainer);
return mod.default;
}

/**
Expand Down Expand Up @@ -182,6 +184,34 @@ document.addEventListener('DOMContentLoaded', async () => {
announcer.className = 'sr-only';
document.body.appendChild(announcer);

/**
* Reference to the currently active game plugin, or null when no game is loaded.
* Used to call stop() when the application quits mid-game.
* @type {object|null}
*/
let activePlugin = null;

// Register the before-quit handler so any mid-game progress is saved when the
// user quits the application. The renderer calls stop() on the active plugin
// (which persists progress via the score service), then notifies the main process
// that it is safe to exit.
if (window.api && typeof window.api.receive === 'function') {
window.api.receive('app:before-quit', async () => {
if (activePlugin && typeof activePlugin.stop === 'function') {
try {
await activePlugin.stop();
} catch (err) {
logger.error('Error saving progress on quit', err);
}
}
try {
await window.api.invoke('app:quit-ready');
} catch (err) {
logger.error('Failed to notify main process of quit-ready', err);
}
});
}

// Load player progress (default player)
let progress = {};
try {
Expand Down Expand Up @@ -284,13 +314,14 @@ document.addEventListener('DOMContentLoaded', async () => {
gameSelector.remove();
hidePlayTimeSummary();
try {
await loadAndInitGame(gameId, gameContainer, announcer);
activePlugin = await loadAndInitGame(gameId, gameContainer, announcer);
} catch (err) {
handleGameLoadError(gameId, gameContainer, announcer, err);
}
});
// Listen for custom event to return to main menu from any game
window.addEventListener('bsx:return-to-main-menu', () => {
activePlugin = null;
// Remove any game UI and its stylesheet
gameContainer.innerHTML = '';
removeGameStylesheet();
Expand Down Expand Up @@ -333,7 +364,7 @@ document.addEventListener('DOMContentLoaded', async () => {
selector.remove();
hidePlayTimeSummary();
try {
await loadAndInitGame(gameId, gameContainer, announcer);
activePlugin = await loadAndInitGame(gameId, gameContainer, announcer);
} catch (err) {
handleGameLoadError(gameId, gameContainer, announcer, err);
}
Expand Down
89 changes: 87 additions & 2 deletions app/interface.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import { jest } from '@jest/globals';
// Mock the game module dynamically imported by loadAndInitGame.
// Must be registered before interface.js is imported.
const mockGameInit = jest.fn();
const mockGameStop = jest.fn().mockResolvedValue({ score: 0 });

// Capture the DOMContentLoaded callback before importing interface.js.
let domReadyCallback;

// jest.unstable_mockModule MUST be called at module top level (with top-level await) so that
// the mock is registered before Jest's coverage instrumentation pre-loads interface.js.
await jest.unstable_mockModule('./games/fast-piggie/index.js', () => ({
default: { init: mockGameInit },
default: { init: mockGameInit, stop: mockGameStop },
}));

// Mock timerService to control date and formatting in tests.
Expand Down Expand Up @@ -67,7 +68,8 @@ function setupApi({ progressData = {}, manifests = MANIFESTS, gameLoad = GAME_LO
if (channel === 'games:load') return Promise.resolve(gameLoad);
return Promise.resolve(null);
});
global.window.api = { invoke, on: jest.fn() };
const receive = jest.fn();
global.window.api = { invoke, on: jest.fn(), receive };
return invoke;
}

Expand Down Expand Up @@ -103,6 +105,7 @@ describe('interface.js', () => {
+ '</div>';
document.head.innerHTML = '';
mockGameInit.mockClear();
mockGameStop.mockClear();
mockClearHistory.mockClear();
});

Expand Down Expand Up @@ -683,4 +686,86 @@ describe('interface.js', () => {
expect(panel.hidden).toBe(false);
});
});

// ── app:before-quit handler ──────────────────────────────────────────────

describe('app:before-quit handler', () => {
/**
* Helper to get the callback registered for 'app:before-quit' via window.api.receive.
* @returns {Function|undefined}
*/
function getQuitCallback() {
const call = global.window.api.receive.mock.calls.find(
([channel]) => channel === 'app:before-quit',
);
return call ? call[1] : undefined;
}

it('registers a receive handler for app:before-quit during DOMContentLoaded', async () => {
setupApi();
await domReadyCallback();
expect(global.window.api.receive).toHaveBeenCalledWith(
'app:before-quit',
expect.any(Function),
);
});

it('calls stop() on the active plugin and then invokes app:quit-ready', async () => {
const invoke = setupApi();
await domReadyCallback();

dispatchGameSelect();
await flush();

const quitCallback = getQuitCallback();
expect(quitCallback).toBeDefined();
await quitCallback();

expect(mockGameStop).toHaveBeenCalled();
expect(invoke).toHaveBeenCalledWith('app:quit-ready');
});

it('invokes app:quit-ready even when no game is active', async () => {
const invoke = setupApi();
await domReadyCallback();

const quitCallback = getQuitCallback();
await quitCallback();

expect(mockGameStop).not.toHaveBeenCalled();
expect(invoke).toHaveBeenCalledWith('app:quit-ready');
});

it('still invokes app:quit-ready when stop() throws', async () => {
const invoke = setupApi();
await domReadyCallback();
dispatchGameSelect();
await flush();

mockGameStop.mockRejectedValueOnce(new Error('stop failed'));

const quitCallback = getQuitCallback();
await quitCallback();

expect(invoke).toHaveBeenCalledWith('app:quit-ready');
});

it('clears activePlugin when returning to main menu', async () => {
const invoke = setupApi();
await domReadyCallback();
dispatchGameSelect();
await flush();

// Return to menu — activePlugin should be cleared.
window.dispatchEvent(new Event('bsx:return-to-main-menu'));
await flush();

const quitCallback = getQuitCallback();
await quitCallback();

// stop() must NOT be called because activePlugin was cleared.
expect(mockGameStop).not.toHaveBeenCalled();
expect(invoke).toHaveBeenCalledWith('app:quit-ready');
});
});
});
15 changes: 15 additions & 0 deletions app/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,25 @@ contextBridge.exposeInMainWorld('api', {
'progress:load',
'progress:reset',
'log:send',
'app:quit-ready',
];
if (validChannels.includes(channel)) {
return ipcRenderer.invoke(channel, data);
}
return Promise.reject(new Error(`Blocked IPC channel: ${channel}`));
},

/**
* Register a one-time listener for a message pushed from the main process.
* The listener is automatically removed after it fires once.
* Silently ignores requests for channels not on the allowlist.
* @param {string} channel - The IPC channel to listen on.
* @param {Function} callback - Callback invoked when the message arrives.
*/
receive: (channel, callback) => {
const validChannels = ['app:before-quit'];
if (validChannels.includes(channel)) {
ipcRenderer.once(channel, (_event, ...args) => callback(...args));
}
},
});
33 changes: 32 additions & 1 deletion app/preload.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { jest } from '@jest/globals';
const mockIpcRenderer = {
send: jest.fn(),
on: jest.fn(),
once: jest.fn(),
invoke: jest.fn().mockResolvedValue('mocked-result'),
};

Expand Down Expand Up @@ -36,17 +37,19 @@ describe('preload.js', () => {
delete global.require;
});

it('exposes "api" with send, receive, and invoke via contextBridge', () => {
it('exposes "api" with invoke and receive via contextBridge', () => {
expect(api).toEqual(
expect.objectContaining({
invoke: expect.any(Function),
receive: expect.any(Function),
}),
);
});

describe('invoke', () => {
it.each([
'games:list', 'games:load', 'progress:save', 'progress:load', 'progress:reset', 'log:send',
'app:quit-ready',
])(
'calls ipcRenderer.invoke for allowed channel "%s"',
async (channel) => {
Expand All @@ -61,4 +64,32 @@ describe('preload.js', () => {
);
});
});

describe('receive', () => {
it('registers an ipcRenderer.once listener for the app:before-quit channel', () => {
const callback = jest.fn();
api.receive('app:before-quit', callback);
expect(mockIpcRenderer.once).toHaveBeenCalledWith('app:before-quit', expect.any(Function));
});

it('invokes the callback with forwarded arguments when the event fires', () => {
const callback = jest.fn();
api.receive('app:before-quit', callback);

// Simulate the main process sending the event.
const [, registeredHandler] = mockIpcRenderer.once.mock.calls.find(
([channel]) => channel === 'app:before-quit',
);
registeredHandler({}, 'extra-arg');
expect(callback).toHaveBeenCalledWith('extra-arg');
});

it('does not register a listener for a blocked channel', () => {
api.receive('blocked_channel', jest.fn());
const blockedCall = mockIpcRenderer.once.mock.calls.find(
([channel]) => channel === 'blocked_channel',
);
expect(blockedCall).toBeUndefined();
});
});
});
47 changes: 45 additions & 2 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,41 @@ app.on('window-all-closed', () => {
}
});

app.on('before-quit', () => {
log.info('BrainSpeedExercises shutting down');
/**
* Whether the quit flow has already been confirmed by the renderer.
* Used to prevent re-entrant handling of before-quit.
* @type {boolean}
*/
let isQuitting = false;

/**
* Milliseconds to wait for the renderer to confirm progress is saved before
* forcing the application to exit.
* @type {number}
*/
const QUIT_TIMEOUT_MS = 5000;

app.on('before-quit', (event) => {
if (isQuitting) return;
event.preventDefault();
log.info('BrainSpeedExercises shutting down — requesting renderer to save progress');

if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('app:before-quit');

// Fallback: force-exit if the renderer does not respond within the timeout.
setTimeout(() => {
if (!isQuitting) {
log.warn('Renderer did not respond before quit timeout — forcing exit');
isQuitting = true;
app.exit(0);
}
}, QUIT_TIMEOUT_MS);
} else {
// No window to ask — exit immediately.
isQuitting = true;
app.exit(0);
}
});

// Extra security filters.
Expand Down Expand Up @@ -146,6 +179,16 @@ ipcMain.handle('progress:save', async (event, { playerId, data }) => saveProgres

ipcMain.handle('progress:reset', async (event, { playerId }) => resetProgress(playerId));

/**
* Renderer confirms that in-progress game state has been saved.
* Clears the quit guard and exits the application.
*/
ipcMain.handle('app:quit-ready', () => {
log.info('Renderer confirmed progress saved — exiting');
isQuitting = true;
app.exit(0);
});

const gamesPath = path.join(app.getAppPath(), 'app', 'games');

ipcMain.handle('games:list', async () => scanGamesDirectory(gamesPath));
Expand Down
Loading