diff --git a/app/interface.js b/app/interface.js index fbeb8c6..0d8dc4a 100644 --- a/app/interface.js +++ b/app/interface.js @@ -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} The initialized game plugin module default export. */ async function loadAndInitGame(gameId, gameContainer, announcer) { const result = await window.api.invoke('games:load', gameId); @@ -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; } /** @@ -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 { @@ -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(); @@ -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); } diff --git a/app/interface.test.js b/app/interface.test.js index 5b1c16d..d3b38f1 100644 --- a/app/interface.test.js +++ b/app/interface.test.js @@ -4,6 +4,7 @@ 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; @@ -11,7 +12,7 @@ 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. @@ -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; } @@ -103,6 +105,7 @@ describe('interface.js', () => { + ''; document.head.innerHTML = ''; mockGameInit.mockClear(); + mockGameStop.mockClear(); mockClearHistory.mockClear(); }); @@ -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'); + }); + }); }); diff --git a/app/preload.js b/app/preload.js index 3518232..d02bc0a 100644 --- a/app/preload.js +++ b/app/preload.js @@ -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)); + } + }, }); diff --git a/app/preload.test.js b/app/preload.test.js index 331d400..f6dd7d4 100644 --- a/app/preload.test.js +++ b/app/preload.test.js @@ -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'), }; @@ -36,10 +37,11 @@ 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), }), ); }); @@ -47,6 +49,7 @@ describe('preload.js', () => { 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) => { @@ -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(); + }); + }); }); diff --git a/main.js b/main.js index 10d5fb2..1c732eb 100644 --- a/main.js +++ b/main.js @@ -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. @@ -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));