Skip to content

Commit

Permalink
feat(template)!: Improve (and add back in) the plugin system for inte…
Browse files Browse the repository at this point in the history
…racting with Electron's "main" process in an easier way

Refs: #1462
Refs: #1373

BREAKING-CHANGE: Plugins must now be registered in the client's `electron/plugins/index.ts` file (instead of the Telestion configuration file).
  • Loading branch information
pklaschka committed Dec 24, 2022
1 parent 44f79e7 commit dd3e0d3
Show file tree
Hide file tree
Showing 9 changed files with 257 additions and 92 deletions.
192 changes: 102 additions & 90 deletions packages/telestion-client-template/template/src/electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,115 +3,127 @@ import { app, BrowserWindow, shell } from 'electron';

import IpcManager from './electron/ipc-manager';
import MenuBuilder from './electron/menu-builder';
import { plugins } from './electron/plugins';
import { PluginManager } from './electron/plugin-manager';
import { OnReadyHook } from './electron/lifecycle-hooks/on-ready-hook';

const pluginManager = new PluginManager(plugins);

(async () => {
function installExtensions() {
const installer = require('electron-devtools-installer');
const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
const extensions = ['REACT_DEVELOPER_TOOLS'];

installer
.default(
extensions.map(name => installer[name]),
forceDownload
)
.then((name: string) => console.log(`Added extension: ${name}`))
.catch((err: any) => console.log('An error occurred:', err));
}

function installExtensions() {
const installer = require('electron-devtools-installer');
const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
const extensions = ['REACT_DEVELOPER_TOOLS'];

installer
.default(
extensions.map(name => installer[name]),
forceDownload
)
.then((name: string) => console.log(`Added extension: ${name}`))
.catch((err: any) => console.log('An error occurred:', err));
}

let mainWindow: BrowserWindow | null = null;

// noinspection JSIgnoredPromiseFromCall
function createWindow() {
mainWindow = new BrowserWindow({
width: 1024,
height: 768,
backgroundColor: '#ffffff',
webPreferences: {
nodeIntegration: false,
nodeIntegrationInWorker: false,
nodeIntegrationInSubFrames: false,
contextIsolation: true,
// disable devtools in packaged apps
devTools: !app.isPackaged,
spellcheck: false,
// load built preload.js next to built electron.js
preload: fileURLToPath(new URL('electron/preload.ts', import.meta.url))
let mainWindow: BrowserWindow | null = null;

// noinspection JSIgnoredPromiseFromCall
async function createWindow() {
mainWindow = new BrowserWindow({
width: 1024,
height: 768,
backgroundColor: '#ffffff',
webPreferences: {
nodeIntegration: false,
nodeIntegrationInWorker: false,
nodeIntegrationInSubFrames: false,
contextIsolation: true,
// disable devtools in packaged apps
devTools: !app.isPackaged,
spellcheck: false,
// load built preload.js next to build electron.js
preload: fileURLToPath(new URL('electron/preload.ts', import.meta.url))
}
});

if (app.isPackaged) {
// when packaged, the `electron.js` resides directly beside the compiled `index.html`
// noinspection JSIgnoredPromiseFromCall
await mainWindow.loadFile('index.html');
} else {
// when run directly, parcel provides a development server at the specified port
// noinspection JSIgnoredPromiseFromCall
await mainWindow.loadURL(
`http://localhost:${process.env.DEV_SERVER_PORT}`
);
}
});

if (app.isPackaged) {
// when packaged, the `electron.js` resides directly beside the compiled `index.html`
// noinspection JSIgnoredPromiseFromCall
mainWindow.loadFile('index.html');
} else {
// when run directly, parcel provides a development server at the specified port
// noinspection JSIgnoredPromiseFromCall
mainWindow.loadURL(`http://localhost:${process.env.DEV_SERVER_PORT}`);
}
// Open the DevTools.
if (!app.isPackaged) mainWindow.webContents.openDevTools();

// generate additional components
const ipcManager = new IpcManager(mainWindow);
const menuBuilder = new MenuBuilder(mainWindow, ipcManager);

// Open the DevTools.
if (!app.isPackaged) mainWindow.webContents.openDevTools();
// and add them to the window
ipcManager.register();
menuBuilder.buildMenu();

// generate additional components
const ipcManager = new IpcManager(mainWindow);
const menuBuilder = new MenuBuilder(mainWindow, ipcManager);
// remove the menu from window
// to activate, also comment out the buildMenu() method above
//mainWindow.removeMenu();

// and add them to the window
ipcManager.register();
menuBuilder.buildMenu();
//
// event handlers for this specific window
//

// remove menu from window
// to activate, also comment out the buildMenu() method above
//mainWindow.removeMenu();
// Open urls in the user's browser
mainWindow.webContents.setWindowOpenHandler(details => {
// noinspection JSIgnoredPromiseFromCall
shell.openExternal(details.url);
return { action: 'deny' };
});

mainWindow.on('closed', () => {
// remove all IPC handlers
ipcManager.unregister();
});
}

//
// event handlers for this specific window
// app global events
//

// Open urls in the user's browser
mainWindow.webContents.setWindowOpenHandler(details => {
// noinspection JSIgnoredPromiseFromCall
shell.openExternal(details.url);
return { action: 'deny' };
});
// 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.
await app.whenReady();

mainWindow.on('closed', () => {
// remove all IPC handlers
ipcManager.unregister();
});
}

//
// app global events
//
await pluginManager.callHook(new OnReadyHook());

// 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(() => {
// install additional devtools if in development mode
if (!app.isPackaged) installExtensions();

// create one window
createWindow();
await createWindow();

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.
app.on('activate', async () => {
// 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) {
createWindow();
await createWindow();
}
});

// 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') {
app.quit();
}
});
});

// 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') {
app.quit();
}
});

// 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.
// 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.
})();
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { LifecycleHook } from '../model/lifecycle-hook';
import { Plugin } from '../model/plugin';

export class OnReadyHook implements LifecycleHook {
readonly name = 'onReady';

async fn(plugins: Array<Plugin>) {
await Promise.allSettled(plugins.map(this.callOnReadyHook));
}

private async callOnReadyHook(plugin: Plugin) {
if (!plugin.onReady || typeof plugin.onReady !== 'function') {
return;
}
await plugin.onReady();
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {
app,
shell,
BrowserWindow,
Menu,
MenuItemConstructorOptions
MenuItemConstructorOptions,
shell
} from 'electron';

import IpcManager from './ipc-manager';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* A value that can be awaited.
*/
export type Awaitable<T> = T | Promise<T>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Plugin } from './plugin';

/**
* A lifecycle hook is a function that is called when a lifecycle event occurs.
*/
export interface LifecycleHook {
/**
* The name of the lifecycle hook.
*
* Used for logging purposes.
*/
readonly name: string;
/**
* The function that is called when the lifecycle hook is executed.
* @param plugins the plugins that are currently loaded
*/
fn: (plugins: Plugin[]) => Promise<void> | void;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* A plugin object containing lifecycle methods for the plugin.
*
* Gets managed by a {@link PluginManager}.
*/
export interface Plugin {
/**
* A lifecycle method
*/
[hook: string]: ((...args: any) => any | Promise<any>) | undefined;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Plugin } from './model/plugin';
import { LifecycleHook } from './model/lifecycle-hook';
import { Awaitable } from './model/awaitable';

/**
* The plugin manager is responsible for loading and managing plugins.
*
* It can be used to load plugins, and to execute lifecycle hooks.
*
* @see {@link LifecycleHook}, {@link Plugin}
*/
export class PluginManager {
/**
* The plugins that are currently installed.
* @private
*/
private plugins: Plugin[] = [];
/**
* A promise that resolves when all plugins are installed.
* @private
*/
private _ready: Promise<any> = Promise.resolve();

/**
* Creates a new plugin manager.
* @param plugins the plugins to initially load
*/
constructor(plugins: Awaitable<Plugin>[]) {
plugins.forEach(plugin => this.installPlugin(plugin));
}

/**
* Installs a plugin.
*
* Installation is done in the background, so the plugin is not immediately available.
* However, it is guaranteed that the plugin is installed before the next lifecycle hook is called.
* @param plugin the plugin to install
*/
installPlugin(plugin: Plugin | Promise<Plugin>): void {
this._ready = Promise.allSettled([
this._ready,
this.validateAndAddPlugin(plugin)
]);
}

/**
* Executes a lifecycle hook.
*
* This method is asynchronous and returns a promise that resolves when all plugins have executed the hook.
*
* Awaits any pending plugin installation completions.
* @param hook the lifecycle hook to execute
*/
async callHook(hook: LifecycleHook) {
await this._ready;
await hook.fn(this.plugins);
}

/**
* Checks if the given object is a valid plugin.
* @param plugin the object to check
* @private
*/
private isPlugin(plugin: unknown): plugin is Plugin {
return (
typeof plugin === 'object' &&
plugin !== null &&
!Array.isArray(plugin) &&
!(plugin instanceof Promise)
);
}

/**
* Validates and adds a plugin to the list of installed plugins if it is a valid plugin.
* Rejects the promise if the plugin is not valid.
* @param plugin the plugin to validate and add
* @private
*/
private async validateAndAddPlugin(plugin: Plugin | Promise<Plugin>) {
const pluginInstance = await plugin;
if (!this.isPlugin(pluginInstance)) {
throw new Error('Plugin is not a valid plugin: ' + pluginInstance);
}
this.plugins.push(pluginInstance);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Plugin } from '../model/plugin';
import { Awaitable } from '../model/awaitable';

/**
* The plugins that get loaded into the Electron main process.
*/
export const plugins: Awaitable<Plugin>[] = [
// Add your plugins here:
// require('./my-plugin'),
require('./test-plugin')
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function onReady() {
// Do something when the application is ready
console.log('Application is ready');
}

export const name = 'app';

0 comments on commit dd3e0d3

Please sign in to comment.