Source

frontend/startup.js

/* This file is part of Ezra Bible App.

   Copyright (C) 2019 - 2023 Ezra Bible App Development Team <contact@ezrabibleapp.net>

   Ezra Bible App is free software: you can redistribute it and/or modify
   it under the terms of the GNU General Public License as published by
   the Free Software Foundation, either version 2 of the License, or
   (at your option) any later version.

   Ezra Bible App is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   GNU General Public License for more details.

   You should have received a copy of the GNU General Public License
   along with Ezra Bible App. See the file LICENSE.
   If not, see <http://www.gnu.org/licenses/>. */

const Mousetrap = require('mousetrap');
const PlatformHelper = require('../lib/platform_helper.js');
const IpcGeneral = require('./ipc/ipc_general.js');
const IpcI18n = require('./ipc/ipc_i18n.js');
const IpcNsi = require('./ipc/ipc_nsi.js');
const IpcDb = require('./ipc/ipc_db.js');
const IpcSettings = require('./ipc/ipc_settings.js');
const i18nController = require('./controllers/i18n_controller.js');
const dbSyncController = require('./controllers/db_sync_controller.js');
const eventController = require('./controllers/event_controller.js');
const cacheController = require('./controllers/cache_controller.js');

// UI Helper
const UiHelper = require('./helpers/ui_helper.js');
window.uiHelper = new UiHelper();

const { html, waitUntilIdle, getPlatform } = require('./helpers/ezra_helper.js');

/**
 * The Startup class has the purpose to start up the application.
 * The main entry point is the method `initApplication()`.
 * @category Startup
 */
class Startup {
  constructor() {
    this._platformHelper = new PlatformHelper();

    window.ipcI18n = null;
    window.ipcNsi = null;
    window.ipcDb = null;
    window.ipcSettings = null;

    window.reference_separator = ':';

    window.app_controller = null;
    window.tags_controller = null;

    if (window.sendCrashReports == null) {
      window.sendCrashReports = false;
    }
  }

  async initTest() {
    if (app.commandLine.hasSwitch('install-kjv')) {
      let repoConfigExisting = await ipcNsi.repositoryConfigExisting();

      if (!repoConfigExisting) {
        $('#loading-subtitle').text("Updating repository config");
        await ipcNsi.updateRepositoryConfig();
      }

      var kjvModule = await ipcNsi.getLocalModule('KJV');
      if (kjvModule == null) {
        $('#loading-subtitle').text("Installing KJV");
        await ipcNsi.installModule('KJV');
      }
    }

    if (app.commandLine.hasSwitch('install-asv')) {
      let repoConfigExisting = await ipcNsi.repositoryConfigExisting();

      if (!repoConfigExisting) {
        $('#loading-subtitle').text("Updating repository config");
        await ipcNsi.updateRepositoryConfig();
      }

      var asvModule = await ipcNsi.getLocalModule('ASV');
      if (asvModule == null) {
        $('#loading-subtitle').text("Installing ASV");
        await ipcNsi.installModule('ASV');
      }
    }
  }

  loadWebComponents() {
    require('./components/tool_panel/panel_buttons.js');
    require('./components/tags/tag_list_menu.js');
    require('./components/tags/tag_group_list.js');
    require('./components/tags/tag_group_assignment_list.js');
    require('./components/tags/tag_list.js');
    require('./components/tags/tag_distribution_matrix.js');
    require('./components/options_menu/config_option.js');
    require('./components/options_menu/select_option.js');
    require('./components/options_menu/locale_switch.js');
    require('./components/module_assistant/module_assistant.js');
    require('./components/verse_context_menu.js');
    require('./components/generic/text_field.js');
  }

  loadHTML() {
    if (!this._platformHelper.isElectron()) {
      window.Buffer = require('buffer/').Buffer;
    }

    this.loadWebComponents();

    var bookSelectionMenu = null;
    var tagSelectionMenu = null;
    var toolPanel = null;
    var tagPanel = null;
    var moduleSearchMenu = null;
    var displayOptionsMenu = null;
    var verseListTabs = null;
    var boxes = null;

    if (this._platformHelper.isElectron() && !isDev) {
      // Electron Production

      const { loadFile } = require('./helpers/fs_helper.js');

      console.log("Loading HTML files via Electron production approach");

      bookSelectionMenu = loadFile('html/book_selection_menu.html');
      tagSelectionMenu = loadFile('html/tag_selection_menu.html');
      toolPanel = loadFile('html/tool_panel.html');
      tagPanel = loadFile('html/tag_panel.html');
      moduleSearchMenu = loadFile('html/module_search_menu.html');
      displayOptionsMenu = loadFile('html/display_options_menu.html');
      verseListTabs = loadFile('html/verse_list_tabs.html');
      boxes = loadFile('html/boxes.html');

    } else {
      // Development & Cordova/Android

      console.log("Loading HTML files via Development / Cordova / Android aproach");

      // Note that for Cordova these readFileSync calls are all inlined, which means the content of those files
      // becomes part of the bundle when bundling up the sources with Browserify.

      const fs = require('fs');

      bookSelectionMenu = fs.readFileSync('html/book_selection_menu.html');
      tagSelectionMenu = fs.readFileSync('html/tag_selection_menu.html');
      toolPanel = fs.readFileSync('html/tool_panel.html');
      tagPanel = fs.readFileSync('html/tag_panel.html');
      moduleSearchMenu = fs.readFileSync('html/module_search_menu.html');
      displayOptionsMenu = fs.readFileSync('html/display_options_menu.html');
      verseListTabs = fs.readFileSync('html/verse_list_tabs.html');
      boxes = fs.readFileSync('html/boxes.html');
    }
  
    document.getElementById('book-selection-menu-book-list').innerHTML = bookSelectionMenu;
    document.getElementById('tag-selection-menu').innerHTML = tagSelectionMenu;
    document.getElementById('tool-panel').innerHTML = toolPanel;
    document.getElementById('tag-panel').innerHTML = tagPanel;
    document.getElementById('module-search-menu').innerHTML = moduleSearchMenu;
    document.getElementById('display-options-menu').innerHTML = displayOptionsMenu;
    document.getElementById('verse-list-tabs').innerHTML = verseListTabs;
    document.getElementById('boxes').innerHTML = boxes;
  }

  async initIpcClients() {
    if (window.ipcGeneral === undefined) {
      window.ipcGeneral = new IpcGeneral();
    }

    if (window.ipcI18n === undefined) {
      window.ipcI18n = new IpcI18n();
    }

    window.ipcNsi = new IpcNsi();
    window.ipcDb = new IpcDb();
    window.ipcSettings = new IpcSettings();
  }

  async initControllers() {
    const AppController = require('./controllers/app_controller.js');
    const TagsController = require('./controllers/tags_controller.js');

    window.app_controller = new AppController();
    await app_controller.init();

    window.tags_controller = new TagsController();

    const ThemeController = require('./controllers/theme_controller.js');
    window.theme_controller = new ThemeController();
  }

  initUi() {
    this._platformHelper.addPlatformCssClass();

    tags_controller.initTagsUI();
    uiHelper.configureButtonStyles();
    
    if (platformHelper.isElectron()) {
      const resizable = require('./components/tool_panel/resizable.js');
      resizable.initResizable();
    }

    window.addEventListener('resize', () => { uiHelper.onResize(); });

    // We need to call onResize initially independent of an event in order to correctly initialize the innerWidth in uiHelper
    uiHelper.onResize();
  }

  async earlyInitNightMode() {
    var useNightMode = await ipcSettings.get('useNightMode', false);

    if (useNightMode) {
      document.body.classList.add('darkmode--activated');
    }
  }

  initExternalLinkHandling() {
    // Open links classified as external in the default web browser
    $('body').on('click', 'a.external, p.external a, div.external a', (event) => {
      event.preventDefault();
      let link = event.target.href;

      if (platformHelper.isElectron()) {

        require("electron").shell.openExternal(link);

      } else if (platformHelper.isCordova()) {

        window.open(link, '_system');

      }
    });
  }

  async confirmPrivacyOptions() {
    const dialogBoxTemplate = html`
      <div id='privacy-options-box-content'>
        <h2 i18n='data-privacy.data-privacy-options'></h2>
        <p i18n='general.welcome-to-ezra-bible-app'></p>

        <p i18n='[html]data-privacy.please-confirm-options'></p>

        <div id='check-new-releases-box'>
          <h3 i18n='general.check-new-releases'></h3>
          <p i18n='[html]data-privacy.check-new-releases-hint'></p>

          <div style='width: 18em;'>
            <config-option id="checkNewReleasesPrivacyOption" settingsKey="checkNewReleases" label="general.check-new-releases" checkedByDefault="true"></config-option>
          </div>
        </div>

        <div id='send-crash-reports-box'>
          <h3 i18n='general.send-crash-reports'></h3>
          <p>
            <span i18n='[html]data-privacy.send-crash-reports-hint-part1'></span>
            <span i18n='[html]data-privacy.send-crash-reports-hint-part2'></span>
            <span i18n='[html]data-privacy.send-crash-reports-hint-part3'></span> 
          </p>

          <div style='width: 18em;'>
            <config-option id="sendCrashReportsPrivacyOption" settingsKey="sendCrashReports" label="general.send-crash-reports" checkedByDefault="true"></config-option>
          </div>
        </div>
      </div>
    `;

    return new Promise((resolve) => {
      document.querySelector('#privacy-options-box').appendChild(dialogBoxTemplate.content);
      const $dialogBox = $('#privacy-options-box');
      $dialogBox.localize();

      let checkNewReleasesOption = document.getElementById('checkNewReleasesPrivacyOption');
      let checkNewReleasesMenuOption = document.getElementById('checkNewReleasesOption');
      checkNewReleasesOption.addEventListener("optionChanged", async () => {
        await checkNewReleasesMenuOption.loadOptionFromSettings();
      });

      let sendCrashReportsOption = document.getElementById('sendCrashReportsPrivacyOption');
      let sendCrashReportsMenuOption = document.getElementById('sendCrashReportsOption');
      sendCrashReportsOption.addEventListener("optionChanged", async () => {
        await sendCrashReportsMenuOption.loadOptionFromSettings();
        await app_controller.optionsMenu.toggleCrashReportsBasedOnOption();
      });

      uiHelper.configureButtonStyles('#privacy-options-box');
      
      const width = 800;
      const height = 600;
      const offsetLeft = ($(window).width() - width)/2;

      let dialogOptions = uiHelper.getDialogOptions(width, height, false, [offsetLeft, 80]);

      var buttons = {};
      buttons[i18n.t('general.ok')] = function() {
        $(this).dialog('close');
      };

      let title = '';

      if (this._platformHelper.isElectron()) { 
        title = i18n.t('data-privacy.confirm-privacy-options');
      } else if (this._platformHelper.isCordova()) {
        title = i18n.t('general.ezra-bible-app') + ' - ' + i18n.t('data-privacy.confirm-privacy-options');
      }

      dialogOptions.buttons = buttons;
      dialogOptions.title = title;
      dialogOptions.resizable = false;
      dialogOptions.modal = true;
      dialogOptions.dialogClass = 'ezra-dialog privacy-options-dialog';
      dialogOptions.close = () => {
        $dialogBox.dialog('destroy');
        $dialogBox.remove();
        resolve();
      };

      Mousetrap.bind('esc', () => { $dialogBox.dialog("close"); });
      $dialogBox.dialog(dialogOptions);
      
      $('.privacy-options-dialog').find('.ui-dialog-titlebar-close').hide();
    });
  }

  async initApplication() {
    console.time("application-startup");

    // Wait for the UI to render
    await waitUntilIdle();

    var isDev = await this._platformHelper.isDebug();

    if (isDev) {
      window.Sentry = {
        addBreadcrumb: function () { },
        Severity: {
          Info: ''
        }
      };
    }

    if (this._platformHelper.isElectron()) {
      window.app = require('@electron/remote').app;

      const { ipcRenderer } = require('electron');
      await ipcRenderer.send('manageWindowState');

      console.log("Initializing IPC handlers ...");
      await ipcRenderer.invoke('initIpc');
    }

    var loadingIndicator = $('#startup-loading-indicator');
    loadingIndicator.show();
    loadingIndicator.find('.loader').show();

    uiHelper.updateLoadingSubtitle("cordova.init-user-interface", "Initializing user interface");

    console.log("Initializing IPC clients ...");
    await this.initIpcClients();

    console.log("Initializing i18n ...");

    // Initialize the localize function as empty first on unsupported platforms.
    // This helps to have a functioning JavaScript environment
    // when opening the index.html file just like that.
    if (window.jQuery && !this._platformHelper.isSupportedPlatform()) {
      jQuery.fn.extend({
        localize: function() { return null; }
      });
    }

    if (this._platformHelper.isElectron()) {
      await i18nController.initI18N();
    } else if (this._platformHelper.isCordova()) {
      // The initI18N call already happened on Cordova, but not yet the initLocale one,
      // because the initLocale call depends on persisting settings which can only be done now (after the permissions setup).
      // At this point, we can write settings and can therefore call initLocale!
      await i18nController.initLocale();
    }

    console.log("Loading HTML fragments");
    this.loadHTML();

    if (this._platformHelper.isCordova()) {
      await this.earlyInitNightMode();
    }

    this.initExternalLinkHandling();

    if (this._platformHelper.isWin()) {
      var isWin10 = await this._platformHelper.isWindowsTenOrLater();
      if (isWin10 != undefined) {
        if (!isWin10) {
          uiHelper.hideGlobalLoadingIndicator();
          var vcppRedistributableNeeded = this._platformHelper.showVcppRedistributableMessageIfNeeded();
          if (vcppRedistributableNeeded) {
            return;
          }
        }
      }
    }

    loadingIndicator.find('.loader').show();

    try {
      $(document).localize();
    } catch (e) {
      console.warn("Could not localize the DOM!");
    }

    if (this._platformHelper.isTest()) {
      await this.initTest();
    }

    console.log("Initializing controllers ...");
    await this.initControllers();

    console.log("Initializing user interface ...");
    this.initUi();
    await app_controller.optionsMenu.init();
    theme_controller.initNightMode();

    // Wait for the UI to render
    await waitUntilIdle();

    console.log("Loading settings ...");
    uiHelper.updateLoadingSubtitle("cordova.loading-settings");
    if (this._platformHelper.isElectron() || this._platformHelper.isCordova()) {
      await app_controller.loadSettings();
    }

    uiHelper.updateLoadingSubtitle("cordova.waiting-app-ready");

    // Wait for the UI to render, before we hide the loading indicator
    await waitUntilIdle();
    loadingIndicator.hide();
    $('#loading-subtitle').hide();

    // Show main content
    document.getElementById('main-content').style.display = 'block';

    await waitUntilIdle();

    // Restore the scroll position of the first tab.
    app_controller.tab_controller.restoreScrollPosition(0);
    // FIXME: Also highlight the last navigation element in the navigation pane and scroll to it

    dbSyncController.init();
    dbSyncController.showSyncResultMessage();

    if (this._platformHelper.isElectron()) {
      const { ipcRenderer } = require('electron');
      ipcRenderer.invoke("startupCompleted");
    }

    console.timeEnd("application-startup");

    // Save some meta data about versions used

    cacheController.saveLastUsedVersion();

    if (platformHelper.isCordova()) {
      ipcSettings.set('lastUsedAndroidVersion', getPlatform().getAndroidVersion());
    }

    // Confirm privacy options at first startup
    const firstStartDone = await ipcSettings.has('firstStartDone');
    if (!this._platformHelper.isTest() && this._platformHelper.isSupportedPlatform() && !firstStartDone) {
      await this.confirmPrivacyOptions();
      await ipcSettings.set('firstStartDone', true);
    }

    // Automatically open module assistant to install Bible translations if no translations are installed yet.
    if (!this._platformHelper.isTest()) {
      const translationCount = app_controller.translation_controller.getTranslationCount();
      if (translationCount == 0) {
        app_controller.openModuleSettingsAssistant('BIBLE'); 
      }
    }

    //await app_controller.translation_controller.installStrongsIfNeeded();

    let checkNewReleasesOption = app_controller.optionsMenu._checkNewReleasesOption;

    if (this._platformHelper.isElectron() && checkNewReleasesOption.isChecked) {
      console.log("Checking for latest release ...");
      const NewReleaseChecker = require('./helpers/new_release_checker.js');
      var newReleaseChecker = new NewReleaseChecker('new-release-info-box');
      newReleaseChecker.check();
    }

    await eventController.publishAsync('on-startup-completed');
    app_controller.startupCompleted = true;
  }

  /** 
   * Localize "Loading" strings early if localStorage available 
   */
  earlyRestoreLocalizedString() {
    const loadingElement = document.querySelector('[i18n="general.loading"]');
    if (loadingElement) {
      loadingElement.textContent = i18nController.getStringForStartup("general.loading", "Loading");
    }

    const loadingSubtitleElement = document.querySelector('#loading-subtitle');
    if (loadingSubtitleElement) {
      loadingSubtitleElement.textContent = i18nController.getStringForStartup("cordova.starting-app", "Starting app");
    }
  }
}

module.exports = Startup;