main.js

// Built-in modules
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const util = require('util');

// Electron modules
const { app, BrowserWindow, ipcMain } = require('electron');

// Extra modules
const getPort = require('get-port');
const isDevMode = require('electron-is-dev');
const { get } = require('axios');

// Set path to error log
const errorLogPath = path.join(
  process.env.APPDATA,
  'MKVToolNix Batch Tool',
  'error.log'
);

// Prepare file for writing logs
const errorLogFile = fs.createWriteStream(errorLogPath, { flags: 'a' });

// Update console.error so stdout is saved to log file
console.error = (...args) => {
  const now = new Date();
  const message = util.format(...args);

  // Ensure message configuration is same as Flask's
  const timestamp = now.toLocaleString('en-US', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit',
    hour12: false
  }).replace(',', '');
  const formattedMessage = `${timestamp} - MKVToolNix Batch Tool - ERROR - ${message}\n`;

  // Write stdout to error.log file
  errorLogFile.write(formattedMessage);
  process.stdout.write(formattedMessage);
};



/**
 * @description - Shuts down Electron & Flask.
 * @param {number} port - Port that Flask server is running on.
 */
const shutdown = (port) => {
  get(`http://localhost:${port}/quit`).then(app.quit).catch(app.quit);
};

/**
 * @namespace BrowserWindow
 * @description - Electron browser windows.
 * @tutorial - https://www.electronjs.org/docs/api/browser-window
 */
const browserWindows = {};

/**
 * @description - Creates main window.
 * @param {number} port - Port that Flask server is running on.
 *
 * @memberof BrowserWindow
 */
const createMainWindow = (port) => {
  const { loadingWindow, mainWindow } = browserWindows;

  /**
   * @description - Function to use custom JavaSCript in the DOM.
   * @param {string} command - JavaScript to execute in DOM.
   * @param {function} callback - Callback to execute here once complete.
   * @returns {promise}
   */
  const executeOnWindow = (command, callback) => {
    return mainWindow.webContents
      .executeJavaScript(command)
      .then(callback)
      .catch(console.error);
  };

  /**
   * If in developer mode, show a loading window while
   * the app and developer server compile.
   */
  if (isDevMode) {
    mainWindow.loadURL('http://localhost:3000');
    mainWindow.hide();

    /**
     * Opening devTools, must be done before dom-ready
     * to avoid occasional error from the webContents
     * object being destroyed.
     */
    mainWindow.webContents.openDevTools({ mode: 'undocked' });

    /**
     * Hide loading window and show main window
     * once the main window is ready.
     */
    mainWindow.webContents.on('did-finish-load', () => {
      /**
       * Checks page for errors that may have occurred
       * during the hot-loading process.
       */
      const isPageLoaded = `
        var isBodyFull = document.body.innerHTML !== "";
        var isHeadFull = document.head.innerHTML !== "";
        var isLoadSuccess = isBodyFull && isHeadFull;

        isLoadSuccess || Boolean(location.reload());
      `;

      /**
       * @description Updates windows if page is loaded
       * @param {*} isLoaded
       */
      const handleLoad = (isLoaded) => {
        if (isLoaded) {
          /**
           * Keep show() & hide() in this order to prevent
           * unresponsive behavior during page load.
           */
          mainWindow.show();
          loadingWindow.destroy();
        }
      };

      /**
       * Checks if the page has been populated with
       * React project. if so, shows the main page.
       */
      executeOnWindow(isPageLoaded, handleLoad);
    });
  } else mainWindow.loadFile(path.join(__dirname, 'build/index.html'));

  /**
   * If using in production, the built version of the
   * React project will be used instead of localhost.
   */

  /**
   * @description - Controls the opacity of title bar on focus/blur.
   * @param {number} value - Opacity to set for title bar.
   */
  const setTitleOpacity = (value) => `
    if(document.readyState === 'complete') {
      const titleBar = document.getElementById('electron-window-title-text');
      const titleButtons = document.getElementById('electron-window-title-buttons');

      if(titleBar) titleBar.style.opacity = ${value};
      if(titleButtons) titleButtons.style.opacity = ${value};
    }
  `;

  mainWindow.on('focus', () => executeOnWindow(setTitleOpacity(1)));
  mainWindow.on('blur', () => executeOnWindow(setTitleOpacity(0.5)));

  /**
   * Listen and respond to ipcRenderer events on the frontend.
   * @see `src\utils\services.js`
   */
  ipcMain.on('app-maximize', mainWindow.maximize);
  ipcMain.on('app-minimize', mainWindow.minimize);
  ipcMain.on('app-quit', () => shutdown(port));
  ipcMain.on('app-unmaximize', mainWindow.unmaximize);
  ipcMain.on('get-port-number', (event) => {
    event.returnValue = port;
  });
  ipcMain.on('app-restart', (_event, options) => {
    // Determine if debug or regular app is used
    const appPath = options.detached
      ? 'app.debug/app.debug.exe'
      : 'app/app.exe';

    // Determines if .py or .exe is used
    const script = isDevMode
      ? 'python app.py'
      : `start ./resources/${appPath}`;

    // Quit Flask, then restart in desired mode
    get(`http://localhost:${port}/quit`)
      .then(() => {
        spawn(`${script} ${port}`, options);

        if (options.detached) {
          mainWindow.webContents.openDevTools({ mode: 'undocked' });
        } else {
          mainWindow.webContents.closeDevTools();
        }
      })
      .catch(console.error);
  });

};

/**
 * @description - Creates loading window to show while build is created.
 * @memberof BrowserWindow
 */
const createLoadingWindow = () => {
  return new Promise((resolve, reject) => {
    const { loadingWindow } = browserWindows;

    const loaderConfig = {
      react: 'utilities/loaders/react/index.html',
      redux: 'utilities/loaders/redux/index.html'
    };

    try {
      loadingWindow.loadFile(path.join(__dirname, loaderConfig.react));

      loadingWindow.webContents.on('did-finish-load', () => {
        resolve(loadingWindow.show());
      });
    } catch (error) {
      reject(console.error(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.
 */
app.whenReady().then(async () => {
  /**
   * Method to set port in range of 3001-3999,
   * based on availability.
   */
  const port = await getPort({
    port: getPort.makeRange(3001, 3999)
  });

  /**
   * Assigns the main browser window on the
   * browserWindows object.
   */
  browserWindows.mainWindow = new BrowserWindow({
    frame: false,
    height: 370,
    maxHeight: 370,
    minHeight: 370,
    width: 675,
    minWidth: 675,
    maxWidth: 675,
    webPreferences: {
      contextIsolation: false,
      enableRemoteModule: true,
      nodeIntegration: true,
      preload: path.join(__dirname, 'preload.js')
    }
  });

  /**
   * If not using in production, use the loading window
   * and run Flask in shell.
   */
  if (isDevMode) {
    (browserWindows.loadingWindow = new BrowserWindow({ frame: false }));
    createLoadingWindow().then(() => createMainWindow(port));
    spawn(`python app.py ${port}`, {
      detached: true,
      shell: true,
      stdio: 'inherit'
    });
  } else {
    /**
     * If using in production, use the main window
     * and run bundled app.exe file.
     */
    createMainWindow(port);
    spawn(`start ./resources/app/app.exe ${port}`, {
      detached: false,
      shell: true,
      stdio: 'pipe'
    });
  }

  app.on('activate', () => {
    /**
     * 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 (BrowserWindow.getAllWindows().length === 0) createMainWindow(port);
  });

  /**
   * Ensures that only a single instance of the app
   * can run, this correlates with the "name" property
   * used in `package.json`.
   */
  const initialInstance = app.requestSingleInstanceLock();
  if (!initialInstance) app.quit();
  else {
    app.on('second-instance', () => {
      if (browserWindows.mainWindow?.isMinimized()) { browserWindows.mainWindow?.restore(); }
      browserWindows.mainWindow?.focus();
    });
  }

  /**
   * Quit when all windows are closed, except on macOS. There, it's common
   * for applications and their menu bar to stay active until the user quits
   * explicitly with Cmd + Q.
   */
  app.on('window-all-closed', () => {
    if (process.platform !== 'darwin') shutdown(port);
  });
});