diff --git a/main.ts b/main.ts new file mode 100644 index 00000000..be70b112 --- /dev/null +++ b/main.ts @@ -0,0 +1,153 @@ +'use strict'; + +import { + app, + BrowserWindow, + BrowserWindowConstructorOptions, + protocol, +} from 'electron'; +import Store from 'electron-store'; +import { autoUpdater } from 'electron-updater'; +import path from 'path'; +import { createProtocol } from 'vue-cli-plugin-electron-builder/lib'; +import { getMainWindowSize } from './main/helpers'; +import registerAppLifecycleListeners from './main/registerAppLifecycleListeners'; +import registerAutoUpdaterListeners from './main/registerAutoUpdaterListeners'; +import registerIpcMainActionListeners from './main/registerIpcMainActionListeners'; +import registerIpcMainMessageListeners from './main/registerIpcMainMessageListeners'; +import registerProcessListeners from './main/registerProcessListeners'; +import { IPC_CHANNELS } from './src/messages'; + +export class Main { + title: string = 'Frappe Books'; + icon: string; + + winURL: string = ''; + isWebpackUrl: boolean = false; + checkedForUpdate = false; + mainWindow: BrowserWindow | null = null; + + constructor() { + this.icon = this.isDevelopment + ? path.resolve('./build/icon.png') + : path.join(__dirname, 'icons', '512x512.png'); + + protocol.registerSchemesAsPrivileged([ + { scheme: 'app', privileges: { secure: true, standard: true } }, + ]); + + if (this.isDevelopment) { + autoUpdater.logger = console; + } + + Store.initRenderer(); + + this.registerListeners(); + if (this.isMac && this.isDevelopment) { + app.dock.setIcon(this.icon); + } + } + + get isDevelopment() { + return process.env.NODE_ENV !== 'production'; + } + + get isMac() { + return process.platform === 'darwin'; + } + + get isLinux() { + return process.platform === 'linux'; + } + + registerListeners() { + registerIpcMainMessageListeners(this); + registerIpcMainActionListeners(this); + registerAutoUpdaterListeners(this); + registerAppLifecycleListeners(this); + registerProcessListeners(this); + } + + getOptions(): BrowserWindowConstructorOptions { + const { width, height } = getMainWindowSize(); + const options: BrowserWindowConstructorOptions = { + vibrancy: 'sidebar', + transparent: this.isMac, + backgroundColor: '#80FFFFFF', + width, + height, + title: this.title, + webPreferences: { + contextIsolation: false, // TODO: Switch this off + nodeIntegration: process.env + .ELECTRON_NODE_INTEGRATION as unknown as boolean, + }, + frame: this.isLinux, + resizable: true, + }; + if (this.isDevelopment || this.isLinux) { + Object.assign(options, { icon: this.icon }); + } + + if (this.isLinux) { + Object.assign(options, { + icon: path.join(__dirname, '/icons/512x512.png'), + }); + } + + return options; + } + + createWindow() { + const options = this.getOptions(); + this.mainWindow = new BrowserWindow(options); + + this.isWebpackUrl = !!process.env.WEBPACK_DEV_SERVER_URL; + if (this.isWebpackUrl) { + this.loadWebpackDevServerURL(); + } else { + this.loadAppUrl(); + } + + this.setMainWindowListeners(); + } + + loadWebpackDevServerURL() { + // Load the url of the dev server if in development mode + this.winURL = process.env.WEBPACK_DEV_SERVER_URL as string; + this.mainWindow!.loadURL(this.winURL); + + if (!process.env.IS_TEST) { + this.mainWindow!.webContents.openDevTools(); + } + } + + loadAppUrl() { + createProtocol('app'); + // Load the index.html when not in development + this.winURL = 'app://./index.html'; + this.mainWindow!.loadURL(this.winURL); + } + + setMainWindowListeners() { + if (this.mainWindow === null) { + return; + } + + this.mainWindow.on('closed', () => { + this.mainWindow = null; + }); + + this.mainWindow.webContents.on('did-finish-load', () => { + if (this.mainWindow === null) { + return; + } + + this.mainWindow.webContents.send(IPC_CHANNELS.STORE_ON_WINDOW, { + appVersion: app.getVersion(), + }); + }); + } +} + +export default new Main(); diff --git a/main/helpers.ts b/main/helpers.ts new file mode 100644 index 00000000..6bebddd2 --- /dev/null +++ b/main/helpers.ts @@ -0,0 +1,14 @@ +import electron, { app } from 'electron'; + +export function getMainWindowSize() { + let height; + if (app.isReady()) { + const screen = electron.screen; + height = screen.getPrimaryDisplay().workAreaSize.height; + height = height > 907 ? 907 : height; + } else { + height = 907; + } + const width = Math.ceil(1.323 * height); + return { height, width }; +} diff --git a/main/registerAppLifecycleListeners.ts b/main/registerAppLifecycleListeners.ts new file mode 100644 index 00000000..71b66276 --- /dev/null +++ b/main/registerAppLifecycleListeners.ts @@ -0,0 +1,32 @@ +import { app } from 'electron'; +import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer'; +import { Main } from '../main'; + +export default function registerAppLifecycleListeners(main: Main) { + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } + }); + + app.on('activate', () => { + if (main.mainWindow === null) { + main.createWindow(); + } + }); + + app.on('ready', async () => { + if (main.isDevelopment && !process.env.IS_TEST) { + try { + await installExtension(VUEJS_DEVTOOLS); + } catch (e) { + console.error( + 'Vue Devtools failed to install:', + (e as Error).toString() + ); + } + } + + main.createWindow(); + }); +} diff --git a/main/registerAutoUpdaterListeners.ts b/main/registerAutoUpdaterListeners.ts new file mode 100644 index 00000000..a334656f --- /dev/null +++ b/main/registerAutoUpdaterListeners.ts @@ -0,0 +1,49 @@ +import { autoUpdater, UpdateInfo } from 'electron-updater'; +import { Main } from '../main'; +import { IPC_CHANNELS } from '../src/messages'; + +export default function registerAutoUpdaterListeners(main: Main) { + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = false; + + autoUpdater.on('checking-for-update', () => { + if (!main.checkedForUpdate) { + return; + } + + main.mainWindow!.webContents.send(IPC_CHANNELS.CHECKING_FOR_UPDATE); + }); + + autoUpdater.on('update-available', (info: UpdateInfo) => { + if (!main.checkedForUpdate) { + main.checkedForUpdate = true; + } + + main.mainWindow!.webContents.send( + IPC_CHANNELS.UPDATE_AVAILABLE, + info.version + ); + }); + + autoUpdater.on('update-not-available', () => { + if (!main.checkedForUpdate) { + main.checkedForUpdate = true; + return; + } + + main.mainWindow!.webContents.send(IPC_CHANNELS.UPDATE_NOT_AVAILABLE); + }); + + autoUpdater.on('update-downloaded', () => { + main.mainWindow!.webContents.send(IPC_CHANNELS.UPDATE_DOWNLOADED); + }); + + autoUpdater.on('error', (error) => { + if (!main.checkedForUpdate) { + main.checkedForUpdate = true; + return; + } + + main.mainWindow!.webContents.send(IPC_CHANNELS.UPDATE_ERROR, error); + }); +} diff --git a/main/registerIpcMainActionListeners.ts b/main/registerIpcMainActionListeners.ts new file mode 100644 index 00000000..8855bac5 --- /dev/null +++ b/main/registerIpcMainActionListeners.ts @@ -0,0 +1,115 @@ +import { dialog, ipcMain } from 'electron'; +import { autoUpdater } from 'electron-updater'; +import fs from 'fs/promises'; +import path from 'path'; +import { Main } from '../main'; +import { getUrlAndTokenString, sendError } from '../src/contactMothership'; +import { getLanguageMap } from '../src/getLanguageMap'; +import { IPC_ACTIONS } from '../src/messages'; +import saveHtmlAsPdf from '../src/saveHtmlAsPdf'; +import { getMainWindowSize } from './helpers'; + +export default function registerIpcMainActionListeners(main: Main) { + ipcMain.handle(IPC_ACTIONS.TOGGLE_MAXIMIZE_CURRENT_WINDOW, (event) => { + const maximizing = main.mainWindow!.isMaximized(); + if (maximizing) { + main.mainWindow!.maximize(); + } else { + main.mainWindow!.unmaximize(); + } + return maximizing; + }); + + ipcMain.handle(IPC_ACTIONS.GET_OPEN_FILEPATH, async (event, options) => { + return await dialog.showOpenDialog(main.mainWindow!, options); + }); + + ipcMain.handle(IPC_ACTIONS.GET_SAVE_FILEPATH, async (event, options) => { + return await dialog.showSaveDialog(main.mainWindow!, options); + }); + + ipcMain.handle(IPC_ACTIONS.GET_PRIMARY_DISPLAY_SIZE, (event) => { + return getMainWindowSize(); + }); + + ipcMain.handle(IPC_ACTIONS.GET_DIALOG_RESPONSE, async (event, options) => { + if (main.isDevelopment || main.isLinux) { + Object.assign(options, { icon: main.icon }); + } + + return await dialog.showMessageBox(main.mainWindow!, options); + }); + + ipcMain.handle(IPC_ACTIONS.SHOW_ERROR, async (event, { title, content }) => { + return await dialog.showErrorBox(title, content); + }); + + ipcMain.handle( + IPC_ACTIONS.SAVE_HTML_AS_PDF, + async (event, html, savePath) => { + return await saveHtmlAsPdf(html, savePath); + } + ); + + ipcMain.handle(IPC_ACTIONS.SAVE_DATA, async (event, data, savePath) => { + return await fs.writeFile(savePath, data, { encoding: 'utf-8' }); + }); + + ipcMain.handle(IPC_ACTIONS.SEND_ERROR, (event, bodyJson) => { + sendError(bodyJson); + }); + + ipcMain.handle(IPC_ACTIONS.CHECK_FOR_UPDATES, (event, force) => { + if (!main.isDevelopment && !main.checkedForUpdate) { + autoUpdater.checkForUpdates(); + } else if (force) { + autoUpdater.checkForUpdates(); + } + }); + + ipcMain.handle(IPC_ACTIONS.GET_LANGUAGE_MAP, async (event, code) => { + const obj = { languageMap: {}, success: true, message: '' }; + try { + obj.languageMap = await getLanguageMap(code, main.isDevelopment); + } catch (err) { + obj.success = false; + obj.message = (err as Error).message; + } + + return obj; + }); + + ipcMain.handle(IPC_ACTIONS.GET_FILE, async (event, options) => { + const response = { + name: '', + filePath: '', + success: false, + data: Buffer.from('', 'utf-8'), + canceled: false, + }; + const { filePaths, canceled } = await dialog.showOpenDialog( + main.mainWindow!, + options + ); + + response.filePath = filePaths?.[0]; + response.canceled = canceled; + + if (!response.filePath) { + return response; + } + + response.success = true; + if (canceled) { + return response; + } + + response.name = path.basename(response.filePath); + response.data = await fs.readFile(response.filePath); + return response; + }); + + ipcMain.handle(IPC_ACTIONS.GET_CREDS, async (event) => { + return await getUrlAndTokenString(); + }); +} diff --git a/main/registerIpcMainMessageListeners.ts b/main/registerIpcMainMessageListeners.ts new file mode 100644 index 00000000..b178edb7 --- /dev/null +++ b/main/registerIpcMainMessageListeners.ts @@ -0,0 +1,54 @@ +import { ipcMain, Menu, shell } from 'electron'; +import { autoUpdater } from 'electron-updater'; +import { Main } from '../main'; +import { IPC_MESSAGES } from '../src/messages'; + +export default function registerIpcMainMessageListeners(main: Main) { + ipcMain.on(IPC_MESSAGES.OPEN_MENU, (event) => { + if (event.sender === null) { + return; + } + + const menu = Menu.getApplicationMenu(); + if (menu === null) { + return; + } + + menu.popup({ window: main.mainWindow! }); + }); + + ipcMain.on(IPC_MESSAGES.RELOAD_MAIN_WINDOW, () => { + main.mainWindow!.reload(); + }); + + ipcMain.on(IPC_MESSAGES.RESIZE_MAIN_WINDOW, (event, size, resizable) => { + const [width, height] = size; + if (!width || !height) return; + main.mainWindow!.setSize(width, height); + main.mainWindow!.setResizable(resizable); + }); + + ipcMain.on(IPC_MESSAGES.CLOSE_CURRENT_WINDOW, (event) => { + main.mainWindow!.close(); + }); + + ipcMain.on(IPC_MESSAGES.MINIMIZE_CURRENT_WINDOW, (event) => { + main.mainWindow!.minimize(); + }); + + ipcMain.on(IPC_MESSAGES.OPEN_EXTERNAL, (event, link) => { + shell.openExternal(link); + }); + + ipcMain.on(IPC_MESSAGES.SHOW_ITEM_IN_FOLDER, (event, filePath) => { + return shell.showItemInFolder(filePath); + }); + + ipcMain.on(IPC_MESSAGES.DOWNLOAD_UPDATE, (event) => { + autoUpdater.downloadUpdate(); + }); + + ipcMain.on(IPC_MESSAGES.INSTALL_UPDATE, (event) => { + autoUpdater.quitAndInstall(true, true); + }); +} diff --git a/main/registerProcessListeners.ts b/main/registerProcessListeners.ts new file mode 100644 index 00000000..c1519a01 --- /dev/null +++ b/main/registerProcessListeners.ts @@ -0,0 +1,18 @@ +import { app } from 'electron'; +import { Main } from '../main'; + +export default function registerProcessListeners(main: Main) { + if (main.isDevelopment) { + if (process.platform === 'win32') { + process.on('message', (data) => { + if (data === 'graceful-exit') { + app.quit(); + } + }); + } else { + process.on('SIGTERM', () => { + app.quit(); + }); + } + } +} diff --git a/package.json b/package.json index 5a18b5bb..eb6ab119 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "postuninstall": "electron-builder install-app-deps", "script:translate": "node scripts/generateTranslations.js" }, - "main": "background.js", "dependencies": { "@popperjs/core": "^2.10.2", "core-js": "^3.19.0", @@ -35,6 +34,7 @@ "devDependencies": { "@babel/core": "^7.16.0", "@babel/eslint-parser": "^7.16.0", + "@types/electron-devtools-installer": "^2.2.0", "@types/lodash": "^4.14.179", "@typescript-eslint/eslint-plugin": "^4.15.1", "@typescript-eslint/parser": "^4.15.1", @@ -69,11 +69,11 @@ "singleQuote": true, "trailingComma": "es5" }, + "engineStrict": true, "engines": { "node": ">=16.13.1 <17", "yarn": "1.22.17" }, - "engineStrict": true, "gitHooks": { "pre-commit": "lint-staged" }, diff --git a/tsconfig.json b/tsconfig.json index be28c3f3..560cf708 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "esnext", + "target": "es2018", "module": "esnext", "strict": true, "allowJs": true, diff --git a/vue.config.js b/vue.config.js index 54f03ca6..c0dacf04 100644 --- a/vue.config.js +++ b/vue.config.js @@ -5,6 +5,7 @@ module.exports = { pluginOptions: { electronBuilder: { nodeIntegration: true, + mainProcessFile: 'main.ts', disableMainProcessTypescript: false, mainProcessTypeChecking: true, chainWebpackRendererProcess: (config) => { diff --git a/yarn.lock b/yarn.lock index 25aa23ae..b06b95c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1221,6 +1221,11 @@ dependencies: "@types/ms" "*" +"@types/electron-devtools-installer@^2.2.0": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@types/electron-devtools-installer/-/electron-devtools-installer-2.2.1.tgz#27cd1e0a612ef070d980555ae32db5898649e813" + integrity sha512-V/gyryjHZi27/VeU5JtO89rqqq7w7d+Is0VYNLseARvR6ad7lXi8UCIJI9NraqNmd1NoGYedB1+tm5Q8UySF8A== + "@types/eslint-scope@^3.7.0": version "3.7.2" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.2.tgz#11e96a868c67acf65bf6f11d10bb89ea71d5e473"