-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.js
More file actions
265 lines (232 loc) · 8.7 KB
/
main.js
File metadata and controls
265 lines (232 loc) · 8.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
/**
* main.js — Electron main process entry point for BrainSpeedExercises.
*
* Responsibilities:
* - Create and manage BrowserWindow instances.
* - Register all IPC handlers (ipcMain.handle).
* - Load the game plugin registry at startup.
* - Proxy save/load requests from the renderer to the progress manager.
*
* @file Main process bootstrap and IPC wiring for BrainSpeedExercises.
*/
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';
import { scanGamesDirectory, loadGame } from './app/games/registry.js';
debug();
// Developer mode flag.
const isDev = !app.isPackaged;
// 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;
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let mainWindow;
/**
* Create the main application window.
*
* @function
* @returns {void}
*/
function createWindow() {
const display = screen.getPrimaryDisplay();
// Create the browser window.
mainWindow = new BrowserWindow({
width: display.workArea.width,
height: display.workArea.height,
frame: true,
webPreferences: {
devTools: isDev,
nodeIntegration: false, // Disable nodeIntegration for security.
nodeIntegrationInWorker: false,
nodeIntegrationInSubFrames: false,
disableBlinkFeatures: 'Auxclick', // See: https://github.com/doyensec/electronegativity/wiki/AUXCLICK_JS_CHECK
contextIsolation: true, // Protect against prototype pollution.
worldSafeExecuteJavaScript: true, // https://github.com/electron/electron/pull/24114
enableRemoteModule: false, // Turn off remote to avoid temptation.
preload: path.join(app.getAppPath(), 'app/preload.js'),
},
});
// and load the index.html of the app.
mainWindow.loadURL(`file://${app.getAppPath()}/app/index.html`);
// Emitted when the window is closed.
mainWindow.on('closed', () => {
// Dereference the window object, usually you would store windows
// in an array if your app supports multi windows, this is the time
// when you should delete the corresponding element.
mainWindow = null;
});
}
/**
* App ready event handler. Initializes the main window.
* @event
*/
app.on('ready', () => {
log.info('BrainSpeedExercises starting up');
createWindow();
});
// Quit when all windows are closed.
app.on('window-all-closed', () => {
// On OS X it is common for applications and their menu bar
// to stay active until the user quits explicitly with Cmd + Q
if (process.platform !== 'darwin') {
app.quit();
}
});
/**
* 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.
// See also: https://github.com/reZach/secure-electron-template
app.on('web-contents-created', (event, contents) => {
// Block navigation.
// https://electronjs.org/docs/tutorial/security#12-disable-or-limit-navigation
contents.on('will-navigate', (navEvent) => {
navEvent.preventDefault();
});
contents.on('will-redirect', (navEvent) => {
navEvent.preventDefault();
});
// https://electronjs.org/docs/tutorial/security#11-verify-webview-options-before-creation
contents.on('will-attach-webview', (webEvent, webPreferences) => {
// Strip away preload scripts.
delete webPreferences.preload;
delete webPreferences.preloadURL;
// Disable Node.js integration.
webPreferences.nodeIntegration = false;
});
// Block new windows from within the App
// https://electronjs.org/docs/tutorial/security#13-disable-or-limit-creation-of-new-windows
contents.setWindowOpenHandler(() => ({ action: 'deny' }));
// Lock down session permissions.
// https://www.electronjs.org/docs/tutorial/security#4-handle-session-permission-requests-from-remote-content
// https://github.com/doyensec/electronegativity/wiki/PERMISSION_REQUEST_HANDLER_GLOBAL_CHECK
session
.fromPartition('persist: secured-partition')
.setPermissionRequestHandler((webContents, permission, callback) => {
callback(false);
});
});
app.on('activate', () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (mainWindow === null) {
createWindow();
}
});
ipcMain.handle('progress:load', async (event, { playerId }) => loadProgress(playerId));
ipcMain.handle('progress:save', async (event, { playerId, data }) => saveProgress(playerId, data));
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));
ipcMain.handle('games:load', async (event, gameId) => {
const { manifest } = await loadGame(gamesPath, gameId);
const htmlFilePath = path.join(gamesPath, gameId, 'interface.html');
const html = await readFile(htmlFilePath, 'utf8').catch(() => {
throw new Error(`Could not read interface HTML for game: ${gameId}`);
});
return { manifest, html };
});
/**
* List image files (PNG and JPEG) in a given game's image subfolder.
* Used by game plugins to dynamically discover available stimulus images.
*
* @param {Electron.IpcMainInvokeEvent} event
* @param {{ gameId: string, subfolder: string }} params
* @returns {Promise<string[]>} Sorted array of filenames (with extension) in the subfolder.
*/
ipcMain.handle('games:listImages', async (event, { gameId, subfolder }) => {
const dirPath = path.join(gamesPath, gameId, 'images', subfolder);
try {
const files = await readdir(dirPath);
return files.filter((f) => /\.(png|jpe?g)$/i.test(f)).sort();
} catch {
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}`);
});