diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 24e8c4b..53f869a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -16,6 +16,7 @@ BrainSpeedExercises is an Electron desktop application that delivers a series of | Linting | [ESLint](https://eslint.org/) (flat config) | Airbnb-style rules; see `.eslint.config.js` | | Accessibility standard | WCAG 2.2 Level AA | Required for all UI | | Progress persistence | Node `fs` (JSON flat-file) via IPC | No external database required | +| Logging | [`electron-log`](https://github.com/megahertz/electron-log) | Main process + IPC bridge for renderer | > **Dependency policy:** All NPM packages are updated regularly. Pin to a major-version range (e.g. `"^38"`) rather than exact versions, and run `npm audit` on every PR. @@ -98,6 +99,7 @@ Valid channels (extend as needed): | `games:load` | renderer → main | Load a specific game by ID | | `progress:save` | renderer → main | Persist player progress | | `progress:load` | renderer → main | Retrieve player progress | +| `log:send` | renderer → main | Forward a log message to `electron-log` | ### 3 — Renderer Process (`app/interface.js`) @@ -210,6 +212,50 @@ game-specific fields that need custom merge logic. --- +### 5c — Logging Service + +All application code **must** use the centralized logging service instead of `console.*` calls. +The `no-console` ESLint rule is enforced; scripts under `scripts/` are exempt. + +#### Main process (`main.js`, `registry.js`, and any other Node-context file) + +Import `electron-log` directly: + +```js +import log from 'electron-log'; +log.warn('Skipping game: manifest missing fields'); +log.error('IPC handler failed', err); +``` + +#### Renderer process (game plugins, `interface.js`, `components/*.js`) + +Import `logger` from the shared Log Service: + +```js +import { logger } from '../../components/logService.js'; +// Adjust the relative path to logService.js as needed. +logger.error('Failed to load game', err); +logger.warn('Unexpected state', { gameId }); +logger.info('Game started', gameId); +``` + +The `logger` object exposes one method per level: `error`, `warn`, `info`, `verbose`, `debug`. +Each call fires a `log:send` IPC message to the main process, where it is written through +`electron-log`. The call is fire-and-forget — logging failures never interrupt gameplay. + +**Never** call `window.api.invoke('log:send', ...)` directly. Always use `logger.*`. + +#### Testing + +Mock `electron-log` with `jest.unstable_mockModule('electron-log', ...)` in Node-environment tests +(`/** @jest-environment node */`). A shared manual mock is also available at +`__mocks__/electron-log.js` for automatic mocking. + +For renderer-side tests that exercise code which calls `logger.*`, set `global.window.api.invoke` +to a `jest.fn()` and assert on `'log:send'` calls, or simply verify the call count is correct. + +--- + ### 5b — Shared Game Screen Components All games **must** use the shared CSS classes defined in `app/style.css` for their welcome and end @@ -270,7 +316,8 @@ Rules summary: - Airbnb-style base: `eslint-config-airbnb-base` - Enforce `const`/`let` (no `var`) - Single quotes, trailing commas, semicolons required -- `no-console` warnings in renderer code; allowed in main process +- `no-console` error in all app code; use the logging service instead (see §5c) +- `no-console` is **off** only for scripts under `scripts/` - Max line length: 100 characters - All files under `app/` and root JS files are linted; `node_modules/` is excluded @@ -308,6 +355,8 @@ Use the vscode built-in test runner whenever possible. The Jest config maps Electron's Node modules to lightweight mocks in `__mocks__/electron.js`. Never import Electron APIs directly in renderer-side code — always use the `window.api` bridge. +A matching mock for `electron-log` lives in `__mocks__/electron-log.js`. Tests for main-process modules that import `electron-log` should use `jest.unstable_mockModule('electron-log', ...)` (see `registry.test.js` for an example). + --- ## Accessibility (WCAG 2.2 AA) diff --git a/__mocks__/electron-log.js b/__mocks__/electron-log.js new file mode 100644 index 0000000..00c49d4 --- /dev/null +++ b/__mocks__/electron-log.js @@ -0,0 +1,30 @@ +/** + * __mocks__/electron-log.js — Jest manual mock for the electron-log package. + * + * Provides stub implementations of every log-level method so that unit tests + * can spy on logging calls without writing to the filesystem or stdout. + * + * @file Jest mock for electron-log. + */ + +import { jest } from '@jest/globals'; + +const electronLog = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + verbose: jest.fn(), + debug: jest.fn(), + initialize: jest.fn(), + transports: { + file: { + level: 'info', + resolvePathFn: jest.fn(), + }, + console: { + level: 'warn', + }, + }, +}; + +export default electronLog; diff --git a/app/components/logService.js b/app/components/logService.js new file mode 100644 index 0000000..3aedee1 --- /dev/null +++ b/app/components/logService.js @@ -0,0 +1,91 @@ +/** + * logService.js — Renderer-side logging service for BrainSpeedExercises. + * + * Forwards log messages from the renderer process to the main process via IPC, + * where they are written through the application's electron-log instance. + * + * All renderer code (interface.js, game plugins, components) should import from + * this module instead of calling console.* directly. + * + * Usage: + * import { logger } from '../../components/logService.js'; + * logger.error('Something went wrong', err); + * logger.warn('Unexpected state', { gameId }); + * logger.info('Game started', gameId); + * + * @file Renderer-side IPC logging wrapper. + */ + +/** + * Valid log level strings accepted by the main-process log handler. + * @readonly + * @enum {string} + */ +export const LOG_LEVELS = /** @type {const} */ ( + ['error', 'warn', 'info', 'verbose', 'debug'] +); + +/** + * Safely serialize a single log argument to a string. + * + * - `Error` instances are serialized as `": \n"` so + * that message and stack are never lost (plain `JSON.stringify` returns `{}` + * for Error objects). + * - Other objects are serialized via `JSON.stringify`; if that throws (e.g. + * circular references) the value falls back to `String(a)`. + * - Primitives are converted with `String()`. + * + * @param {*} a - The value to serialize. + * @returns {string} + */ +function serializeArg(a) { + if (a instanceof Error) { + return a.stack ? `${a.name}: ${a.message}\n${a.stack}` : `${a.name}: ${a.message}`; + } + if (typeof a === 'object' && a !== null) { + try { + return JSON.stringify(a); + } catch { + return String(a); + } + } + return String(a); +} + +/** + * Send a log message to the main process via the `log:send` IPC channel. + * + * Safe to call when `window.api` is unavailable (e.g., in a test environment + * without a real DOM); the call is silently ignored in that case. + * + * @param {string} level - One of the {@link LOG_LEVELS} values. + * @param {...*} args - Message parts; joined with a space before sending. + * @returns {void} + */ +export function log(level, ...args) { + if (typeof window === 'undefined' || !window.api) return; + const message = args.map(serializeArg).join(' '); + // Fire-and-forget: if the IPC channel is unavailable (e.g., main process restarting) + // the logging call fails silently so it never disrupts the caller. + window.api.invoke('log:send', { level, message }).catch(() => {}); +} + +/** + * Structured logger object with one method per log level. + * + * Each method forwards its arguments to {@link log} with the matching level. + * + * @namespace logger + */ +export const logger = { + /** Log an error-level message. @param {...*} args */ + error: (...args) => log('error', ...args), + /** Log a warning-level message. @param {...*} args */ + warn: (...args) => log('warn', ...args), + /** Log an info-level message. @param {...*} args */ + info: (...args) => log('info', ...args), + /** Log a verbose-level message. @param {...*} args */ + verbose: (...args) => log('verbose', ...args), + /** Log a debug-level message. @param {...*} args */ + debug: (...args) => log('debug', ...args), +}; diff --git a/app/components/tests/logService.test.js b/app/components/tests/logService.test.js new file mode 100644 index 0000000..f9aeff3 --- /dev/null +++ b/app/components/tests/logService.test.js @@ -0,0 +1,158 @@ +/** @jest-environment node */ +/** + * logService.test.js — Unit tests for the renderer-side logging service. + * + * Exercises log(), logger.*, and the LOG_LEVELS export against a mocked + * window.api IPC bridge. + * + * @file Tests for app/components/logService.js + */ + +import { jest } from '@jest/globals'; + +// ── Module-level mock setup ─────────────────────────────────────────────────── + +const { log, logger, LOG_LEVELS } = await import('../logService.js'); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Build a mock window.api object that records invoke calls. + * @returns {{ mock: jest.Mock }} + */ +function buildApiMock() { + const mock = jest.fn(() => Promise.resolve()); + return { mock }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('LOG_LEVELS', () => { + it('exports an array of valid log level strings', () => { + expect(LOG_LEVELS).toEqual( + expect.arrayContaining(['error', 'warn', 'info', 'verbose', 'debug']), + ); + expect(LOG_LEVELS).not.toContain('silly'); + }); +}); + +describe('log()', () => { + afterEach(() => { + delete global.window; + }); + + it('does nothing when window is undefined', () => { + // No global.window — should not throw. + expect(() => log('info', 'test message')).not.toThrow(); + }); + + it('does nothing when window.api is absent', () => { + global.window = {}; + expect(() => log('info', 'test message')).not.toThrow(); + }); + + it('calls window.api.invoke with log:send and the correct payload', () => { + const { mock } = buildApiMock(); + global.window = { api: { invoke: mock } }; + + log('error', 'something broke'); + + expect(mock).toHaveBeenCalledWith('log:send', { + level: 'error', + message: 'something broke', + }); + }); + + it('joins multiple arguments into a single space-separated message string', () => { + const { mock } = buildApiMock(); + global.window = { api: { invoke: mock } }; + + log('warn', 'part1', 'part2', 'part3'); + + expect(mock).toHaveBeenCalledWith('log:send', { + level: 'warn', + message: 'part1 part2 part3', + }); + }); + + it('serializes object arguments to JSON', () => { + const { mock } = buildApiMock(); + global.window = { api: { invoke: mock } }; + + log('debug', 'data:', { key: 'value' }); + + expect(mock).toHaveBeenCalledWith('log:send', { + level: 'debug', + message: 'data: {"key":"value"}', + }); + }); + + it('serializes Error arguments to include name, message, and stack', () => { + const { mock } = buildApiMock(); + global.window = { api: { invoke: mock } }; + + const err = new Error('something broke'); + log('error', err); + + const call = mock.mock.calls[0][1]; + expect(call.message).toContain('Error: something broke'); + }); + + it('falls back to String() for circular objects that cannot be JSON-serialized', () => { + const { mock } = buildApiMock(); + global.window = { api: { invoke: mock } }; + + const circular = {}; + circular.self = circular; + expect(() => log('warn', 'circular:', circular)).not.toThrow(); + expect(mock).toHaveBeenCalled(); + }); + + it('serializes null as the string "null"', () => { + const { mock } = buildApiMock(); + global.window = { api: { invoke: mock } }; + + log('info', null); + + expect(mock).toHaveBeenCalledWith('log:send', { + level: 'info', + message: 'null', + }); + }); + + it('passes the provided level through unchanged', () => { + const { mock } = buildApiMock(); + global.window = { api: { invoke: mock } }; + + log('verbose', 'verbose message'); + + expect(mock).toHaveBeenCalledWith('log:send', { + level: 'verbose', + message: 'verbose message', + }); + }); +}); + +describe('logger', () => { + let mock; + + beforeEach(() => { + ({ mock } = buildApiMock()); + global.window = { api: { invoke: mock } }; + }); + + afterEach(() => { + delete global.window; + }); + + it.each(['error', 'warn', 'info', 'verbose', 'debug'])( + 'logger.%s() sends level="%s" to the IPC channel', + (level) => { + logger[level]('test'); + expect(mock).toHaveBeenCalledWith('log:send', { + level, + message: 'test', + }); + }, + ); +}); diff --git a/app/games/registry.js b/app/games/registry.js index 802ae27..e832141 100644 --- a/app/games/registry.js +++ b/app/games/registry.js @@ -10,6 +10,7 @@ import fs from 'fs/promises'; import path from 'path'; +import log from 'electron-log'; const REQUIRED_FIELDS = ['id', 'name', 'description', 'entryPoint']; @@ -36,7 +37,7 @@ export async function scanGamesDirectory(gamesPath) { const manifest = JSON.parse(raw); const missingFields = REQUIRED_FIELDS.filter((f) => !manifest[f]); if (missingFields.length > 0) { - console.warn( + log.warn( `Skipping game "${dir.name}": manifest missing required fields: ${missingFields.join(', ')}`, ); return null; @@ -46,7 +47,7 @@ export async function scanGamesDirectory(gamesPath) { } return manifest; } catch (err) { - console.warn(`Skipping game "${dir.name}": ${err.message}`); + log.warn(`Skipping game "${dir.name}": ${err.message}`); return null; } }), diff --git a/app/games/registry.test.js b/app/games/registry.test.js index 2591a34..588004e 100644 --- a/app/games/registry.test.js +++ b/app/games/registry.test.js @@ -25,10 +25,22 @@ jest.unstable_mockModule('fs/promises', () => ({ }, })); -// In Jest's ESM VM-modules mode, directly assigning a jest.fn() to -// console.warn is more reliable than jest.spyOn across the module boundary. -const mockConsoleWarn = jest.fn(); -const originalConsoleWarn = console.warn; +const mockLogWarn = jest.fn(); + +jest.unstable_mockModule('electron-log', () => ({ + default: { + error: jest.fn(), + warn: mockLogWarn, + info: jest.fn(), + verbose: jest.fn(), + debug: jest.fn(), + initialize: jest.fn(), + transports: { + file: { level: 'info', resolvePathFn: jest.fn() }, + console: { level: 'warn' }, + }, + }, +})); const mockPlugin = { name: 'Test Game', @@ -50,13 +62,8 @@ function dirent(name, isDir = true) { beforeEach(() => { jest.resetAllMocks(); - // Re-attach the warn mock after resetAllMocks clears its state. - console.warn = mockConsoleWarn; }); -afterAll(() => { - console.warn = originalConsoleWarn; -}); // ─── scanGamesDirectory ─────────────────────────────────────────────────────── @@ -99,7 +106,7 @@ describe('scanGamesDirectory', () => { const result = await scanGamesDirectory(GAMES_PATH); expect(result).toHaveLength(0); - expect(mockConsoleWarn).toHaveBeenCalledWith(expect.stringContaining('bad-game')); + expect(mockLogWarn).toHaveBeenCalledWith(expect.stringContaining('bad-game')); }); test('skips (with a warning) entries whose manifest.json cannot be read', async () => { @@ -109,7 +116,7 @@ describe('scanGamesDirectory', () => { const result = await scanGamesDirectory(GAMES_PATH); expect(result).toHaveLength(0); - expect(mockConsoleWarn).toHaveBeenCalledWith(expect.stringContaining('broken-game')); + expect(mockLogWarn).toHaveBeenCalledWith(expect.stringContaining('broken-game')); }); test('skips (with a warning) entries with malformed JSON in manifest', async () => { @@ -119,7 +126,7 @@ describe('scanGamesDirectory', () => { const result = await scanGamesDirectory(GAMES_PATH); expect(result).toHaveLength(0); - expect(mockConsoleWarn).toHaveBeenCalled(); + expect(mockLogWarn).toHaveBeenCalled(); }); test('rejects when the games directory does not exist', async () => { diff --git a/app/interface.js b/app/interface.js index b0fbd72..fbeb8c6 100644 --- a/app/interface.js +++ b/app/interface.js @@ -10,6 +10,7 @@ import { createGameCard } from './components/gameCard.js'; import { buildHistoryPanel } from './components/historyView.js'; import { formatDuration, getTodayDateString } from './components/timerService.js'; import { clearHistory } from './components/scoreService.js'; +import { logger } from './components/logService.js'; /** * Inject a game-specific stylesheet into the document . @@ -105,8 +106,7 @@ async function loadAndInitGame(gameId, gameContainer, announcer) { * @param {Error} [err] - The error that caused the failure. */ function handleGameLoadError(gameId, gameContainer, announcer, err) { - // eslint-disable-next-line no-console - console.error(`Failed to load game "${gameId}".`, err); + logger.error(`Failed to load game "${gameId}".`, err); announcer.textContent = 'Failed to load game. Returning to menu.'; // Return to the game-selection screen so the player is not left on a blank page. window.dispatchEvent(new Event('bsx:return-to-main-menu')); @@ -196,8 +196,7 @@ document.addEventListener('DOMContentLoaded', async () => { try { manifests = await window.api.invoke('games:list'); } catch (err) { - // eslint-disable-next-line no-console - console.error('Failed to load game list:', err); + logger.error('Failed to load game list:', err); gameSelector.textContent = 'Unable to load games. Please restart the app.'; } manifests.forEach((manifest) => { @@ -325,8 +324,7 @@ document.addEventListener('DOMContentLoaded', async () => { viewHistoryBtn.addEventListener('click', historyBtnHandler); } }).catch((err) => { - // eslint-disable-next-line no-console - console.error('Failed to reload progress or game list after returning to menu.', err); + logger.error('Failed to reload progress or game list after returning to menu.', err); updatePlayTimeSummary({}); }); // Re-attach event listener for game selection diff --git a/app/interface.test.js b/app/interface.test.js index 54001f6..5b1c16d 100644 --- a/app/interface.test.js +++ b/app/interface.test.js @@ -157,7 +157,6 @@ describe('interface.js', () => { }); it('shows an error message when games:list rejects', async () => { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { }); const invoke = jest.fn().mockImplementation((channel) => { if (channel === 'progress:load') return Promise.resolve({}); if (channel === 'games:list') return Promise.reject(new Error('IPC error')); @@ -165,10 +164,12 @@ describe('interface.js', () => { }); global.window.api = { invoke, on: jest.fn() }; await domReadyCallback(); - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to load game list:', expect.any(Error)); + expect(invoke).toHaveBeenCalledWith('log:send', expect.objectContaining({ + level: 'error', + message: expect.stringContaining('Failed to load game list:'), + })); expect(document.getElementById('game-selector').textContent) .toContain('Unable to load games'); - consoleErrorSpy.mockRestore(); }); it('shows the play-time bar after loading', async () => { @@ -648,7 +649,6 @@ describe('interface.js', () => { describe('bsx:return-to-main-menu error handling', () => { it('handles Promise.all rejection gracefully without leaving UI broken', async () => { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn()); const invoke = setupApi(); await domReadyCallback(); @@ -663,7 +663,6 @@ describe('interface.js', () => { // The app should not crash; play-time bar should still be in the DOM. expect(document.getElementById('play-time-bar')).not.toBeNull(); - consoleErrorSpy.mockRestore(); }); }); diff --git a/app/preload.js b/app/preload.js index a04fbc3..3518232 100644 --- a/app/preload.js +++ b/app/preload.js @@ -29,6 +29,7 @@ contextBridge.exposeInMainWorld('api', { 'progress:save', 'progress:load', 'progress:reset', + 'log:send', ]; if (validChannels.includes(channel)) { return ipcRenderer.invoke(channel, data); diff --git a/app/preload.test.js b/app/preload.test.js index c50b222..331d400 100644 --- a/app/preload.test.js +++ b/app/preload.test.js @@ -45,7 +45,9 @@ describe('preload.js', () => { }); describe('invoke', () => { - it.each(['games:list', 'games:load', 'progress:save', 'progress:load', 'progress:reset'])( + it.each([ + 'games:list', 'games:load', 'progress:save', 'progress:load', 'progress:reset', 'log:send', + ])( 'calls ipcRenderer.invoke for allowed channel "%s"', async (channel) => { await api.invoke(channel, { data: 1 }); diff --git a/app/progress/progressManager.js b/app/progress/progressManager.js index 858cb2c..bcb81b2 100644 --- a/app/progress/progressManager.js +++ b/app/progress/progressManager.js @@ -10,6 +10,7 @@ import fs from 'fs/promises'; import path from 'path'; import { app } from 'electron'; +import log from 'electron-log'; function validatePlayerId(playerId) { if (typeof playerId !== 'string' || playerId.trim() === '') { @@ -58,6 +59,7 @@ export async function saveProgress(playerId, data) { const tmpPath = `${filePath}.tmp`; await fs.writeFile(tmpPath, JSON.stringify(toSave, null, 2), 'utf8'); await fs.rename(tmpPath, filePath); + log.info(`Progress saved for player "${playerId}": ${filePath}`); } /** diff --git a/app/progress/progressManager.test.js b/app/progress/progressManager.test.js index ef170a9..dcec85b 100644 --- a/app/progress/progressManager.test.js +++ b/app/progress/progressManager.test.js @@ -6,6 +6,7 @@ const mockWriteFile = jest.fn(); const mockRename = jest.fn(); const mockUnlink = jest.fn(); const mockGetPath = jest.fn(); +const mockLogInfo = jest.fn(); jest.unstable_mockModule('fs/promises', () => ({ default: { @@ -20,6 +21,21 @@ jest.unstable_mockModule('electron', () => ({ app: { getPath: mockGetPath }, })); +jest.unstable_mockModule('electron-log', () => ({ + default: { + error: jest.fn(), + warn: jest.fn(), + info: mockLogInfo, + verbose: jest.fn(), + debug: jest.fn(), + initialize: jest.fn(), + transports: { + file: { level: 'info', resolvePathFn: jest.fn() }, + console: { level: 'warn' }, + }, + }, +})); + const { loadProgress, saveProgress, resetProgress } = await import('./progressManager.js'); beforeEach(() => { @@ -95,6 +111,14 @@ describe('saveProgress', () => { expect(written.lastUpdated >= before).toBe(true); expect(written.lastUpdated <= after).toBe(true); }); + + test('logs an info message including the saved file path', async () => { + await saveProgress('player1', { playerId: 'player1', games: {} }); + + expect(mockLogInfo).toHaveBeenCalledWith( + expect.stringContaining('/mock/userData/player1.json'), + ); + }); }); describe('resetProgress', () => { diff --git a/eslint.config.js b/eslint.config.js index f8d6ade..1454945 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,12 +15,12 @@ export default [ semi: ['error', 'always'], 'comma-dangle': ['error', 'always-multiline'], 'max-len': ['error', { code: 100, ignoreUrls: true, ignoreTemplateLiterals: true }], - 'no-console': 'warn', + 'no-console': 'error', }, }, - // Main process (Node.js) + // Scripts (Node.js) — console allowed for build tooling { - files: ['main.js', 'forge.config.cjs', 'scripts/**/*.js'], + files: ['forge.config.cjs', 'scripts/**/*.js'], languageOptions: { globals: { ...globals.node, @@ -30,36 +30,33 @@ export default [ 'no-console': 'off', }, }, - // Renderer process (browser) + // Main process and Node.js backend — use electron-log, not console { - files: ['app/interface.js', 'app/components/**/*.js'], + files: ['main.js', 'app/games/registry.js', 'app/progress/**/*.js'], languageOptions: { globals: { - ...globals.browser, + ...globals.node, }, }, }, - // Preload script (CommonJS + Node) + // Renderer process (browser) { - files: ['app/preload.js'], + files: ['app/interface.js', 'app/components/**/*.js'], languageOptions: { globals: { - ...globals.node, - ...globals.commonjs, + ...globals.browser, }, }, }, - // Game registry and progress (Node.js backend) + // Preload script (CommonJS + Node) { - files: ['app/games/registry.js', 'app/progress/**/*.js'], + files: ['app/preload.js'], languageOptions: { globals: { ...globals.node, + ...globals.commonjs, }, }, - rules: { - 'no-console': 'off', - }, }, // Game plugin source files (can use browser globals) { diff --git a/jest.config.js b/jest.config.js index b503ada..4e12653 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,6 +9,9 @@ export default { testEnvironment: 'jsdom', transform: {}, testMatch: ['**/*.test.js'], + moduleNameMapper: { + '^electron-log$': '/__mocks__/electron-log.js', + }, coverageThreshold: { global: { functions: 100, diff --git a/main.js b/main.js index bccdbad..10d5fb2 100644 --- a/main.js +++ b/main.js @@ -11,6 +11,7 @@ */ import { app, BrowserWindow, ipcMain, session, screen } from 'electron'; import debug from 'electron-debug'; +import log from 'electron-log'; import { readFile, readdir } from 'fs/promises'; import path from 'path'; import { loadProgress, saveProgress, resetProgress } from './app/progress/progressManager.js'; @@ -21,7 +22,12 @@ debug(); // Developer mode flag. const isDev = !app.isPackaged; -debug(); +// Initialize electron-log, then configure transport levels. +// initialize() must be called first so that default transports are created +// before we override their levels. +log.initialize(); +log.transports.file.level = 'info'; +log.transports.console.level = isDev ? 'debug' : 'warn'; // Get rid of the deprecated default. app.allowRendererProcessReuse = true; @@ -72,7 +78,10 @@ function createWindow() { * App ready event handler. Initializes the main window. * @event */ -app.on('ready', createWindow); +app.on('ready', () => { + log.info('BrainSpeedExercises starting up'); + createWindow(); +}); // Quit when all windows are closed. app.on('window-all-closed', () => { @@ -83,6 +92,10 @@ app.on('window-all-closed', () => { } }); +app.on('before-quit', () => { + log.info('BrainSpeedExercises shutting down'); +}); + // Extra security filters. // See also: https://github.com/reZach/secure-electron-template app.on('web-contents-created', (event, contents) => { @@ -163,3 +176,47 @@ ipcMain.handle('games:listImages', async (event, { gameId, subfolder }) => { return []; } }); + +/** + * Maximum number of characters accepted from renderer-provided log messages. + * + * @type {number} + */ +const MAX_RENDERER_LOG_MESSAGE_LENGTH = 1000; + +/** + * Normalize an untrusted renderer log payload into safe values for logging. + * + * @param {unknown} payload Untrusted IPC payload from the renderer process. + * @returns {{ level: string, message: string }} Safe log level and message values. + */ +function normalizeRendererLogPayload(payload) { + const validLevels = ['error', 'warn', 'info', 'verbose', 'debug']; + const parsedPayload = payload !== null + && typeof payload === 'object' + && !Array.isArray(payload) + ? payload + : {}; + const level = typeof parsedPayload.level === 'string' + && validLevels.includes(parsedPayload.level) + ? parsedPayload.level + : 'info'; + const message = String(parsedPayload.message ?? '') + .slice(0, MAX_RENDERER_LOG_MESSAGE_LENGTH); + + return { level, message }; +} + +/** + * Receive a log message from a renderer process and write it through electron-log. + * + * The renderer sends `{ level, message }` via the `log:send` IPC channel. + * Unrecognised levels fall back to `info`. + * + * @param {Electron.IpcMainInvokeEvent} event + * @param {unknown} payload Untrusted renderer log payload. + */ +ipcMain.handle('log:send', (event, payload) => { + const { level, message } = normalizeRendererLogPayload(payload); + log[level](`[renderer] ${message}`); +}); diff --git a/package-lock.json b/package-lock.json index 3c4248e..fa4cb11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "electron-debug": "^4" + "electron-debug": "^4", + "electron-log": "^5" }, "devDependencies": { "@babel/core": "^7", @@ -9605,6 +9606,15 @@ "keyboardevents-areequal": "^0.2.1" } }, + "node_modules/electron-log": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.3.tgz", + "integrity": "sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.321", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", diff --git a/package.json b/package.json index 26d862c..0e11ac6 100644 --- a/package.json +++ b/package.json @@ -27,28 +27,29 @@ "publish": "electron-forge publish" }, "dependencies": { - "electron-debug": "^4" + "electron-debug": "^4", + "electron-log": "^5" }, "overrides": { "yauzl": "^3.2.1" }, "devDependencies": { - "electron": "^39", "@babel/core": "^7", "@babel/preset-env": "^7", + "@electron-forge/cli": "^7", + "@electron-forge/maker-deb": "^7", + "@electron-forge/maker-dmg": "^7", + "@electron-forge/maker-rpm": "^7", + "@electron-forge/maker-squirrel": "^7", + "@electron-forge/publisher-github": "^7", "@jest/globals": "^29", "babel-jest": "^29", + "electron": "^39", "eslint": "^9", + "icon-gen": "^4", "jest": "^30", "jest-environment-jsdom": "^30", - "@electron-forge/cli": "^7", - "@electron-forge/maker-dmg": "^7", - "@electron-forge/maker-squirrel": "^7", - "@electron-forge/maker-deb": "^7", - "@electron-forge/maker-rpm": "^7", - "@electron-forge/publisher-github": "^7", - "sharp": "^0.33", - "icon-gen": "^4" + "sharp": "^0.33" }, "author": "Aaron Crosman", "license": "MIT",