diff --git a/public/config/trigger.js b/public/config/trigger.js deleted file mode 100644 index d3dd64ca3..000000000 --- a/public/config/trigger.js +++ /dev/null @@ -1,26 +0,0 @@ -// TODO 306: Put this in src/config/trigger, pass to Electron in setConfig -// Event trigger settings - used in both the react app (renderer) and the electron app (main) -// teensyduino -const vendorId = "16c0"; -const productId = ""; - -// brainvision - will be used if product Id (line 4) or process.env.EVENT_MARKER_PRODUCT_ID are not set -// commName can be changed with environment variable process.env.EVENT_MARKER_COM_NAME -const comName = "COM3"; - -// NOTE - these event codes must match what is in public/config/trigger.js -const eventCodes = { - fixation: 1, - evidence: 5, - show_earnings: 7, - test_connect: 32, - open_task: 18, -}; - -// this is module.exports instead of just exports as it is also imported into the electron app -module.exports = { - vendorId, - productId, - eventCodes, - comName, -}; diff --git a/public/electron.js b/public/electron.js deleted file mode 100644 index d542a263c..000000000 --- a/public/electron.js +++ /dev/null @@ -1,364 +0,0 @@ -// Modules to control application life and create native browser window -const { app, BrowserWindow, dialog } = require("electron"); -const path = require("path"); -const ipc = require("electron").ipcMain; -const _ = require("lodash"); -const fs = require("fs-extra"); -const log = require("electron-log"); - -// Event Trigger -const { eventCodes, vendorId, productId, comName } = require("./config/trigger"); -const { getPort, sendToPort } = require("event-marker"); - -// handle windows installer set up -if (require("electron-squirrel-startup")) app.quit(); - -// Define default environment variables -let USE_EEG = false; -let VIDEO = false; - -// Override product ID if environment variable set -const activeProductId = process.env.EVENT_MARKER_PRODUCT_ID || productId; -const activeComName = process.env.EVENT_MARKER_COM_NAME || comName; -if (activeProductId) { - log.info("Active product ID", activeProductId); -} else { - log.info("COM Name", activeComName); -} - -// 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. -// !DONE -let mainWindow; -function createWindow() { - // Create the browser window. - if (process.env.ELECTRON_START_URL) { - // in dev mode, disable web security to allow local file loading - console.log(process.env.ELECTRON_START_URL); - mainWindow = new BrowserWindow({ - width: 1500, - height: 900, - icon: "./favicon.ico", - webPreferences: { - nodeIntegration: true, - contextIsolation: false, - }, - }); - } else { - mainWindow = new BrowserWindow({ - fullscreen: true, - icon: "./favicon.ico", - frame: false, - webPreferences: { - nodeIntegration: true, - webSecurity: true, - contextIsolation: false, - }, - }); - } - - // and load the index.html of the app. - const startUrl = - process.env.ELECTRON_START_URL || `file://${path.join(__dirname, "../build/index.html")}`; - log.info(startUrl); - mainWindow.loadURL(startUrl); - - // Open the DevTools. - process.env.ELECTRON_START_URL && mainWindow.webContents.openDevTools(); - - // Emitted when the window is closed. - mainWindow.on("closed", function () { - // 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; - }); -} - -// TRIGGER PORT HELPERS -let triggerPort; -let portAvailable; -let SKIP_SENDING_DEV = false; - -const setUpPort = async () => { - let p; - if (activeProductId) { - p = await getPort(vendorId, activeProductId); - } else { - p = await getPort(activeComName); - } - if (p) { - triggerPort = p; - portAvailable = true; - - triggerPort.on("error", (err) => { - log.error(err); - const buttons = ["OK"]; - if (process.env.ELECTRON_START_URL) { - buttons.push("Continue Anyway"); - } - dialog - .showMessageBox(mainWindow, { - type: "error", - message: "Error communicating with event marker.", - title: "Task Error", - buttons, - defaultId: 0, - }) - .then((opt) => { - if (opt.response === 0) { - app.exit(); - } else { - SKIP_SENDING_DEV = true; - portAvailable = false; - triggerPort = false; - } - }); - }); - } else { - triggerPort = false; - portAvailable = false; - } -}; - -const handleEventSend = (code) => { - if (!portAvailable && !SKIP_SENDING_DEV) { - const message = "Event Marker not connected"; - log.warn(message); - - const buttons = ["Quit", "Retry"]; - if (process.env.ELECTRON_START_URL) { - buttons.push("Continue Anyway"); - } - dialog - .showMessageBox(mainWindow, { - type: "error", - message, - title: "Task Error", - buttons, - defaultId: 0, - }) - .then((resp) => { - const opt = resp.response; - if (opt === 0) { - // quit - app.exit(); - } else if (opt === 1) { - // retry - setUpPort().then(() => handleEventSend(code)); - } else if (opt === 2) { - SKIP_SENDING_DEV = true; - } - }); - } else if (!SKIP_SENDING_DEV) { - sendToPort(triggerPort, code); - } -}; - -// Update env variables with buildtime values from frontend -// ! DONE -ipc.on("updateEnvironmentVariables", (event, args) => { - USE_EEG = args.USE_EEG; - VIDEO = args.USE_CAMERA; - if (USE_EEG) { - setUpPort().then(() => handleEventSend(eventCodes.test_connect)); - } -}); - -// EVENT TRIGGER - -ipc.on("trigger", (event, args) => { - const code = args; - if (code !== undefined) { - log.info(`Event: ${_.invert(eventCodes)[code]}, code: ${code}`); - if (USE_EEG) { - handleEventSend(code); - } - } -}); - -// will be created on Desktop and used as root folder for saving data. -// data save format is ~/Desktop////.json -// it is also incrementally saved to the user's app data folder (logged to console) - -// INCREMENTAL FILE SAVING -let stream = false; -let fileCreated = false; -let preSavePath = ""; -let savePath = ""; -let participantID = ""; -let studyID = ""; -const images = []; -let startTrial = -1; -const today = new Date(); - -/** - * Abstracts constructing the filepath for saving data for this participant and study. - * @returns {string} The filepath. - */ -// ! NOT USING -const getSavePath = (studyID, participantID) => { - if (studyID !== "" && participantID !== "") { - const desktop = app.getPath("desktop"); - const name = app.getName(); - const date = today.toISOString().slice(0, 10); - return path.join(desktop, studyID, participantID, date, name); - } -}; - -// ! NOT USING -const getFullPath = (fileName) => { - return path.join(savePath, fileName); -}; - -// Read version file (git sha and branch) -// ! DONE -const git = JSON.parse(fs.readFileSync(path.resolve(__dirname, "config/version.json"))); - -// Get Participant Id and Study Id from environment -// ! DONE -ipc.on("syncCredentials", (event) => { - event.returnValue = { - envParticipantId: process.env.REACT_APP_PARTICIPANT_ID, - envStudyId: process.env.REACT_APP_STUDY_ID, - }; -}); - -// listener for new data -// ! DONE -ipc.on("data", (event, args) => { - // initialize file - we got a participant_id to save the data to - if (args.study_id && args.participant_id && !fileCreated) { - const dir = app.getPath("userData"); - participantID = args.participant_id; - studyID = args.study_id; - preSavePath = path.resolve(dir, `pid_${participantID}_${today.getTime()}.json`); - startTrial = args.trial_index; - log.warn(preSavePath); - stream = fs.createWriteStream(preSavePath, { flags: "ax+" }); - stream.write("["); - fileCreated = true; - } - - if (savePath === "") { - savePath = getSavePath(studyID, participantID); - } - - // we have a set up stream to write to, write to it! - if (stream) { - // write intermediate commas - if (args.trial_index > startTrial) { - stream.write(","); - } - - // write the data - stream.write(JSON.stringify({ ...args, git })); - - // Copy provocation images to participant's data folder - if (args.trial_type === "image-keyboard-response") images.push(args.stimulus.slice(7)); - } -}); - -// Save Video -// ! DONE -ipc.on("save_video", (event, videoFileName, buffer) => { - if (savePath === "") { - savePath = getSavePath(studyID, participantID); - } - - if (VIDEO) { - const fullPath = getFullPath(videoFileName); - fs.outputFile(fullPath, buffer, (err) => { - if (err) { - event.sender.send("ERROR", err.message); - } else { - event.sender.send("SAVED_FILE", fullPath); - console.log(fullPath); - } - }); - } -}); - -// EXPERIMENT END -// ! DONE -ipc.on("end", () => { - // quit app - app.quit(); -}); - -// Error state sent from front end to back end (e.g. wrong number of images) -// ! Never used (errors are handled in React) -ipc.on("error", (event, args) => { - log.error(args); - const buttons = ["OK"]; - if (process.env.ELECTRON_START_URL) { - buttons.push("Continue Anyway"); - } - const opt = dialog.showMessageBoxSync(mainWindow, { - type: "error", - message: args, - title: "Task Error", - buttons, - }); - - if (opt === 0) app.exit(); -}); - -// log uncaught exceptions -process.on("uncaughtException", (error) => { - // Handle the error - log.error(error); - - // this isn't dev, throw up a dialog - if (!process.env.ELECTRON_START_URL) { - dialog.showMessageBoxSync(mainWindow, { type: "error", message: error, title: "Task Error" }); - } -}); - -// This method will be called when Electron has finished -// initialization and is ready to create browser windows. -// Some APIs can only be used after this event occurs. -// ! DONE -app.on("ready", () => { - createWindow(); -}); - -// Quit when all windows are closed. -// ! DONE -app.on("window-all-closed", function () { - // On macOS 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(); -}); - -// ! DONE -app.on("activate", function () { - // On macOS 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(); -}); - -// In this file you can include the rest of your app's specific main process -// code. You can also put them in separate files and require them here. - -// EXPERIMENT END -// ! DONE -app.on("will-quit", () => { - if (fileCreated) { - // finish writing file - stream.write("]"); - stream.end(); - stream = false; - - // copy file to config location - const fullPath = getFullPath(`pid_${participantID}_${today.getTime()}.json`); - try { - fs.mkdirSync(savePath, { recursive: true }); - fs.copyFileSync(preSavePath, fullPath); - } catch (e) { - console.error("Unable to save file: ", fullPath); - console.error(e); - log.error(e); - } - } -}); diff --git a/public/electron/main.js b/public/electron/main.js index 0e9649ddb..ae0f7c7f4 100644 --- a/public/electron/main.js +++ b/public/electron/main.js @@ -1,10 +1,14 @@ /** ELECTRON MAIN PROCESS */ -const { app, BrowserWindow, ipcMain } = require("electron"); +const { app, BrowserWindow, ipcMain, dialog } = require("electron"); const log = require("electron-log"); +const _ = require("lodash"); +const url = require("url"); const path = require("node:path"); const fs = require("node:fs"); -const url = require("url"); + +// TODO: Use Electron's web serial API for this +const { getPort, sendToPort } = require("event-marker"); // Early exit when installing on Windows: https://www.electronforge.io/config/makers/squirrel.windows#handling-startup-events if (require("electron-squirrel-startup")) app.quit(); @@ -12,7 +16,6 @@ if (require("electron-squirrel-startup")) app.quit(); // Initialize the logger for any renderer process log.initialize({ preload: true }); -// TODO 306: Handle trigger.js config in the same way as this, delete from public folder // TODO: Initialize writeable stream on login // TODO: Handle data writing to desktop in a utility process? // TODO: Handle video data writing to desktop in a utility process? @@ -21,11 +24,16 @@ log.initialize({ preload: true }); /************ GLOBALS ***********/ let CONFIG; // Honeycomb configuration object +let DEV_MODE; // Whether or not the application is running in dev mode let WRITE_STREAM; // Writeable file stream for the data (in the user's appData folder) // TODO: These should use path, and can be combined into one? let OUT_PATH; // Path to the final output file (on the Desktop) let OUT_FILE; // Name of the output file +let TRIGGER_CODES; // Trigger codes and IDs for the EEG machine +let TRIGGER_PORT; // Port that the EEG machine is talking through + +// TODO: THis is causing an error cause it's not built into the app? const GIT_VERSION = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../config/version.json"))); /************ APP LIFECYCLE ***********/ @@ -40,11 +48,13 @@ app.whenReady().then(() => { // Handle ipcRenderer events (on is renderer -> main, handle is renderer <--> main) ipcMain.on("setConfig", handleSetConfig); + ipcMain.on("setTrigger", handleSetTrigger); ipcMain.handle("getCredentials", handleGetCredentials); ipcMain.on("onDataUpdate", handleOnDataUpdate); ipcMain.on("onFinish", handleOnFinish); ipcMain.on("photodiodeTrigger", handlePhotoDiodeTrigger); ipcMain.on("saveVideo", handleSaveVideo); + ipcMain.handle("checkSerialPort", handleCheckSerialPort); // Setup min files and create the Electron window setupLocalFilesNormalizerProxy(); @@ -81,57 +91,11 @@ app.on("before-quit", () => { } }); -/********** HELPERS **********/ - -/** Creates a new Electron window. */ -function createWindow() { - const mainWindow = new BrowserWindow({ - width: 1500, - height: 900, - icon: "./favicon.ico", - webPreferences: { - preload: path.join(__dirname, "preload.js"), - }, - }); - - /** - * Load the app into the Electron window - * In production it loads the local bundle created by the build process - * In development we use ELECTRON_START_URL (This allows hot-reloading) - */ - const appURL = - process.env.ELECTRON_START_URL || - url.format({ - pathname: path.join(__dirname, "index.html"), - protocol: "file:", - slashes: true, - }); - log.info("Loading URL: ", appURL); - mainWindow.loadURL(appURL); - - // Open the dev tools in development - if (process.env.ELECTRON_START_URL) mainWindow.webContents.openDevTools(); - // Maximize the window in production - else mainWindow.maximize(); -} - -/** - * Set up a local proxy to adjust the paths of requested files - * when loading them from the production bundle (e.g. local fonts, etc...). - */ -// TODO: This is deprecated but needed to load the min files? https://www.electronjs.org/docs/latest/api/protocol#protocolhandlescheme-handler -function setupLocalFilesNormalizerProxy() { - // protocol.registerHttpProtocol( - // "file", - // (request, callback) => { - // const url = request.url.substr(8); - // callback({ path: path.normalize(`${__dirname}/${url}`) }); - // }, - // (error) => { - // if (error) console.error("Failed to register protocol"); - // } - // ); -} +/** Log any uncaught exceptions before quitting */ +process.on("uncaughtException", (error) => { + log.error(error); + app.quit(); +}); /*********** RENDERER EVENT HANDLERS ***********/ @@ -145,6 +109,16 @@ function handleSetConfig(event, config) { log.info("Honeycomb Configuration: ", CONFIG); } +/** + * Receives the Honeycomb config settings and passes them to the CONFIG global in this file + * @param {Event} event The Electron renderer event + * @param {Object} config The current Honeycomb configuration + */ +function handleSetTrigger(event, trigger) { + TRIGGER_CODES = trigger; + log.info("Trigger Codes: ", TRIGGER_CODES); +} + /** * Checks for REACT_APP_STUDY_ID and REACT_APP_PARTICIPANT_ID environment variables * Note that studyID and participantID are undefined when the environment variables are not given @@ -158,6 +132,22 @@ function handleGetCredentials() { return { studyID, participantID }; } +/** + * @returns {Boolean} Whether or not the EEG machine is connected to the computer + */ +function handleCheckSerialPort() { + setUpPort().then(() => handleEventSend(TRIGGER_CODES.eventCodes.test_connect)); +} + +function handlePhotoDiodeTrigger(event, code) { + if (code !== undefined) { + log.info(`Event: ${_.invert(TRIGGER_CODES.eventCodes)[code]}, code: ${code}`); + handleEventSend(code); + } else { + log.warn("Photodiode event triggered but no code was sent"); + } +} + /** * Receives the trial data and writes it to a temp file in AppData * The out path/file and writable stream are initialized if isn't yet @@ -224,10 +214,6 @@ function handleOnFinish() { log.info("Successfully saved experiment data to ", filePath); } -function handlePhotoDiodeTrigger() { - log.info("PHOTODIODE TRIGGER"); -} - // Save webm video file // TODO: Rolling save of webm video, remux to mp4 at the end? function handleSaveVideo(event, data) { @@ -245,6 +231,7 @@ function handleSaveVideo(event, data) { const videoData = Buffer.from(data.split(",")[1], "base64"); fs.mkdirSync(OUT_PATH, { recursive: true }); + // TODO: Convert to mp4 before final save? https://gist.github.com/AVGP/4c2ce4ab3c67760a0f30a9d54544a060 fs.writeFileSync(path.join(OUT_PATH, filePath), videoData); } catch (e) { log.error.error("Unable to save file: ", filePath); @@ -252,3 +239,157 @@ function handleSaveVideo(event, data) { } log.info("Successfully saved video file to ", filePath); } + +/********** HELPERS **********/ + +/** Creates a new Electron window. */ +function createWindow() { + const mainWindow = new BrowserWindow({ + width: 1500, + height: 900, + icon: "./favicon.ico", + webPreferences: { + preload: path.join(__dirname, "preload.js"), + }, + }); + + /** + * Load the app into the Electron window + * In production it loads the local bundle created by the build process + * In development we use ELECTRON_START_URL (This allows hot-reloading) + */ + const appURL = + process.env.ELECTRON_START_URL || + url.format({ + pathname: path.join(__dirname, "index.html"), + protocol: "file:", + slashes: true, + }); + log.info("Loading URL: ", appURL); + mainWindow.loadURL(appURL); + + // Open the dev tools in development + if (process.env.ELECTRON_START_URL) mainWindow.webContents.openDevTools(); + // Maximize the window in production + else mainWindow.maximize(); +} + +/** + * Set up a local proxy to adjust the paths of requested files + * when loading them from the production bundle (e.g. local fonts, etc...). + */ +// TODO: This is deprecated but needed to load the min files? https://www.electronjs.org/docs/latest/api/protocol#protocolhandlescheme-handler +function setupLocalFilesNormalizerProxy() { + // protocol.registerHttpProtocol( + // "file", + // (request, callback) => { + // const url = request.url.substr(8); + // callback({ path: path.normalize(`${__dirname}/${url}`) }); + // }, + // (error) => { + // if (error) console.error("Failed to register protocol"); + // } + // ); +} + +/** SERIAL PORT SETUP & COMMUNICATION (EVENT MARKER) */ + +/** + * Checks the connection to an EEG machine via USB ports + */ +async function setUpPort() { + log.info("Setting up USB port"); + const { productID, comName, vendorID } = TRIGGER_CODES; + + let maybePort; + if (productID) { + // Check port based on productID + log.info("Received a product ID:", productID); + maybePort = await getPort(vendorID, productID); + } else { + // Check port based on COM name + log.info("No product ID, defaulting to COM:", comName); + maybePort = await getPort(comName); + } + + if (maybePort !== false) { + TRIGGER_PORT = maybePort; + + // Show dialog box if trigger port has any errors + TRIGGER_PORT.on("error", (err) => { + log.error(err); + + // Disable as a dialog if there Electron is unable to communicate with the event marker's serial port + // TODO: Let this just be dialog.showErrorBox? + dialog + .showMessageBox(null, { + type: "error", + message: "There was an error with event marker's serial port.", + title: "USB Error", + buttons: [ + "OK", + // Allow continuation when running in development mode + ...(process.env.ELECTRON_START_URL ? ["Continue Anyway"] : []), + ], + defaultId: 0, + }) + .then((opt) => { + log.info(opt); + if (opt.response === 0) { + // Quit app when user selects "OK" + app.exit(); + } else { + // User selected "Continue Anyway", we must be in dev mode + DEV_MODE = true; + TRIGGER_PORT = undefined; + } + }); + }); + } else { + // Unable to connect to a port + TRIGGER_PORT = undefined; + log.warn("USB port was not connected"); + } +} + +/** + * Handles the sending of an event code to TRIGGER_PORT + * @param code The code to send via USB + */ +function handleEventSend(code) { + log.info(`Sending USB event ${code} to port ${TRIGGER_PORT}`); + if (TRIGGER_PORT !== undefined && !DEV_MODE) { + sendToPort(TRIGGER_PORT, code); + } else { + log.error(`Trigger port is undefined - Event Marker is not connected`); + + // Display error menu + const response = dialog.showMessageBoxSync(null, { + type: "error", + message: "Event Marker is not connected", + title: "USB Error", + buttons: [ + "Quit", + "Retry", + // Allow continuation when running in development mode + ...(process.env.ELECTRON_START_URL ? ["Continue Anyway"] : []), + ], + detail: "heres some detail", + }); + + switch (response) { + case 0: + // User selects "Quit" + app.exit(); + break; + case 1: + // User selects "Retry" so we reset the port and try again + setUpPort().then(() => handleEventSend(code)); + break; + case 2: + // User selects "Continue Anyway", we must be in dev mode + DEV_MODE = true; + break; + } + } +} diff --git a/public/electron/preload.js b/public/electron/preload.js index bb400bb22..bd0d2f5a8 100644 --- a/public/electron/preload.js +++ b/public/electron/preload.js @@ -4,10 +4,12 @@ const { contextBridge, ipcRenderer } = require("electron"); process.once("loaded", () => { contextBridge.exposeInMainWorld("electronAPI", { setConfig: (config) => ipcRenderer.send("setConfig", config), + setTrigger: (triggerCodes) => ipcRenderer.send("setTrigger", triggerCodes), getCredentials: () => ipcRenderer.invoke("getCredentials"), on_data_update: (data) => ipcRenderer.send("onDataUpdate", data), on_finish: () => ipcRenderer.send("onFinish"), photodiodeTrigger: () => ipcRenderer.send("photodiodeTrigger"), saveVideo: (data) => ipcRenderer.send("saveVideo", data), + checkSerialPort: () => ipcRenderer.invoke("checkSerialPort"), }); }); diff --git a/public/index.html b/public/index.html index a153003c4..e54c64b4e 100644 --- a/public/index.html +++ b/public/index.html @@ -4,6 +4,9 @@ + + + @@ -16,7 +19,6 @@ -