From ab90b5106df2d16f18430ce2c10975deb81d8753 Mon Sep 17 00:00:00 2001 From: Kifungo A <45813955+adkif@users.noreply.github.com> Date: Mon, 26 Aug 2024 21:16:23 +0200 Subject: [PATCH] Feat/plugin system improvement (#8080) * refactor: handleRowSelection method in PluginListComponent * feat: added status updates and notifications for plugin events, refactored plugin listener and notification services, and updated plugin components to handle new status updates * refactor desktop menu and plugin system: update imports, add plugin event manager, and modify plugin manager and plugin interfaces (cherry picked from commit 8edb57d97f483fa8acd32e949de6683724a21c9b) (cherry picked from commit 10a24572db7829ffbd53f05d3b5675c38a7b6a5b) --- packages/desktop-libs/src/lib/desktop-menu.ts | 44 +++++++++++++++-- .../data-access/plugin-manager.ts | 48 ++++++++++++++----- .../events/plugin-event.manager.ts | 29 +++++++++++ .../lib/plugin-system/events/plugin.event.ts | 20 +++++++- .../interfaces/plugin-manager.interface.ts | 2 + .../shared/interfaces/plugin.interface.ts | 9 ++-- .../services/toastr-notification.service.ts | 6 ++- .../add-plugin/add-plugin.component.ts | 29 ++++++++--- .../plugin-list/plugin-list.component.ts | 30 ++++++++++-- 9 files changed, 184 insertions(+), 33 deletions(-) create mode 100644 packages/desktop-libs/src/lib/plugin-system/events/plugin-event.manager.ts diff --git a/packages/desktop-libs/src/lib/desktop-menu.ts b/packages/desktop-libs/src/lib/desktop-menu.ts index 65617c97b26..9870254cf28 100644 --- a/packages/desktop-libs/src/lib/desktop-menu.ts +++ b/packages/desktop-libs/src/lib/desktop-menu.ts @@ -1,11 +1,16 @@ +import { createAboutWindow, createSettingsWindow } from '@gauzy/desktop-window'; import { BrowserWindow, Menu, MenuItemConstructorOptions, shell } from 'electron'; import { LocalStore } from './desktop-store'; -import { createSettingsWindow, createAboutWindow } from '@gauzy/desktop-window'; -import { TranslateService } from './translation'; import { TimerService } from './offline'; +import { PluginManager } from './plugin-system/data-access/plugin-manager'; +import { PluginEventManager } from './plugin-system/events/plugin-event.manager'; +import { TranslateService } from './translation'; export class AppMenu { public menu: MenuItemConstructorOptions[] = []; + private readonly pluginManager = PluginManager.getInstance(); + private readonly pluginEventManager = PluginEventManager.getInstance(); + constructor(timeTrackerWindow, settingsWindow, updaterWindow, knex, windowPath, serverWindow?, isZoomVisible?) { const isZoomEnabled = isZoomVisible; this.menu = [ @@ -63,7 +68,11 @@ export class AppMenu { enabled: true, async click() { if (!settingsWindow) { - settingsWindow = await createSettingsWindow(settingsWindow, windowPath.timeTrackerUi, windowPath.preloadPath); + settingsWindow = await createSettingsWindow( + settingsWindow, + windowPath.timeTrackerUi, + windowPath.preloadPath + ); } settingsWindow.show(); settingsWindow.webContents.send('app_setting', LocalStore.getApplicationConfig()); @@ -108,6 +117,7 @@ export class AppMenu { } ] }, + this.pluginMenu, { label: TranslateService.instant('TIMER_TRACKER.MENU.HELP'), submenu: [ @@ -159,9 +169,37 @@ export class AppMenu { if (updaterWindow) { updaterWindow.webContents.send('refresh_menu'); } + + this.pluginEventManager.listen(() => { + // Determine if the updated menu + const updatedMenu = this.menu.map((menu) => (menu.id === 'plugin-menu' ? this.pluginMenu : menu)); + // Only rebuild the menu if there was an actual change + if (!this.deepArrayEqual(this.menu, updatedMenu)) { + this.menu = updatedMenu; + this.build(); + console.log('Menu rebuilt after plugin update.'); + } else { + console.log('Plugin update detected, but no changes were made to the menu.'); + } + }); } public build(): void { Menu.setApplicationMenu(Menu.buildFromTemplate([...this.menu])); } + + public get pluginMenu(): MenuItemConstructorOptions { + const submenu = this.pluginManager.getMenuPlugins(); + + return { + id: 'plugin-menu', + label: TranslateService.instant('TIMER_TRACKER.SETTINGS.PLUGIN'), + visible: submenu.length > 0, + submenu + } as MenuItemConstructorOptions; + } + + private deepArrayEqual(arr1: T, arr2: T) { + return JSON.stringify(arr1) === JSON.stringify(arr2); + } } diff --git a/packages/desktop-libs/src/lib/plugin-system/data-access/plugin-manager.ts b/packages/desktop-libs/src/lib/plugin-system/data-access/plugin-manager.ts index 4ba3825e68f..451b025bb03 100644 --- a/packages/desktop-libs/src/lib/plugin-system/data-access/plugin-manager.ts +++ b/packages/desktop-libs/src/lib/plugin-system/data-access/plugin-manager.ts @@ -1,18 +1,30 @@ -import { app } from 'electron'; +import { app, MenuItemConstructorOptions } from 'electron'; import * as logger from 'electron-log'; import * as fs from 'fs'; import * as path from 'path'; import { PluginMetadataService } from '../database/plugin-metadata.service'; +import { PluginEventManager } from '../events/plugin-event.manager'; import { IPlugin, IPluginManager, IPluginMetadata, PluginDownloadContextType } from '../shared'; import { lazyLoader } from '../shared/lazy-loader'; import { DownloadContextFactory } from './download-context.factory'; export class PluginManager implements IPluginManager { private plugins: Map = new Map(); - private activePlugins: Set = new Set(); + private activePlugins: Set = new Set(); private pluginMetadataService = new PluginMetadataService(); private pluginPath = path.join(app.getPath('userData'), 'plugins'); private factory = DownloadContextFactory; + private eventManager = PluginEventManager.getInstance(); + private static instance: IPluginManager; + + private constructor() {} + + public static getInstance(): IPluginManager { + if (!this.instance) { + this.instance = new PluginManager(); + } + return this.instance; + } public async downloadPlugin(config: U, contextType?: PluginDownloadContextType): Promise { logger.info(`Downloading plugin...`); @@ -25,7 +37,7 @@ export class PluginManager implements IPluginManager { } else { await this.installPlugin(metadata, pathDirname); } - fs.rmSync(pathDirname, { recursive: true, force: true }); + fs.rmSync(pathDirname, { recursive: true, force: true, retryDelay: 1000, maxRetries: 3 }); process.noAsar = false; } @@ -86,19 +98,19 @@ export class PluginManager implements IPluginManager { public async activatePlugin(name: string): Promise { const plugin = this.plugins.get(name); if (plugin) { - plugin.activate(); - this.activePlugins.add(name); + await plugin.activate(); + this.activePlugins.add(plugin); await this.pluginMetadataService.update({ name, isActivate: true }); - plugin.initialize(); + await plugin.initialize(); } } public async deactivatePlugin(name: string): Promise { const plugin = this.plugins.get(name); if (plugin) { - plugin.dispose(); - plugin.deactivate(); - this.activePlugins.delete(name); + await plugin.dispose(); + await plugin.deactivate(); + this.activePlugins.delete(plugin); await this.pluginMetadataService.update({ name, isActivate: false }); } } @@ -110,7 +122,7 @@ export class PluginManager implements IPluginManager { await this.deactivatePlugin(name); this.plugins.delete(name); await this.pluginMetadataService.delete({ name }); - fs.rmSync(metadata.pathname, { recursive: true, force: true }); + fs.rmSync(metadata.pathname, { recursive: true, force: true, retryDelay: 1000, maxRetries: 3 }); logger.info(`Uninstalling plugin ${name}`); } } @@ -127,6 +139,7 @@ export class PluginManager implements IPluginManager { await this.activatePlugin(metadata.name); } } + this.eventManager.notify(); } public getAllPlugins(): Promise { @@ -138,10 +151,21 @@ export class PluginManager implements IPluginManager { } public initializePlugins(): void { - this.plugins.forEach((plugin) => plugin.initialize()); + this.activePlugins.forEach(async (plugin) => await plugin.initialize()); } public disposePlugins(): void { - this.plugins.forEach((plugin) => plugin.dispose()); + this.activePlugins.forEach(async (plugin) => await plugin.dispose()); + } + + public getMenuPlugins(): MenuItemConstructorOptions[] { + try { + const plugins = Array.from(this.activePlugins); + logger.info('Active Plugins:', plugins); + return plugins.map((plugin) => plugin?.menu).filter((menu): menu is MenuItemConstructorOptions => !!menu); + } catch (error) { + logger.error('Error retrieving plugin submenu:', error); + return []; + } } } diff --git a/packages/desktop-libs/src/lib/plugin-system/events/plugin-event.manager.ts b/packages/desktop-libs/src/lib/plugin-system/events/plugin-event.manager.ts new file mode 100644 index 00000000000..ff1ea2c08c1 --- /dev/null +++ b/packages/desktop-libs/src/lib/plugin-system/events/plugin-event.manager.ts @@ -0,0 +1,29 @@ +import EventEmitter from 'events'; + +export enum PluginEvent { + NOTIFY = 'on::plugin::notify' +} + +export class PluginEventManager extends EventEmitter { + private static _instance: PluginEventManager; + + private constructor() { + super(); + this.removeAllListeners(PluginEvent.NOTIFY); + } + + public static getInstance(): PluginEventManager { + if (!this._instance) { + this._instance = new PluginEventManager(); + } + return PluginEventManager._instance; + } + + public notify(message?: string): void { + this.emit(PluginEvent.NOTIFY, message); + } + + public listen(listener: (message?: string) => T) { + this.on(PluginEvent.NOTIFY, listener.bind(this)); + } +} diff --git a/packages/desktop-libs/src/lib/plugin-system/events/plugin.event.ts b/packages/desktop-libs/src/lib/plugin-system/events/plugin.event.ts index 583ee0ef2db..08975c4857a 100644 --- a/packages/desktop-libs/src/lib/plugin-system/events/plugin.event.ts +++ b/packages/desktop-libs/src/lib/plugin-system/events/plugin.event.ts @@ -2,9 +2,11 @@ import { ipcMain, IpcMainEvent } from 'electron'; import * as logger from 'electron-log'; import { PluginManager } from '../data-access/plugin-manager'; import { IPluginManager, PluginChannel, PluginHandlerChannel } from '../shared'; +import { PluginEventManager } from './plugin-event.manager'; class ElectronPluginListener { private pluginManager: IPluginManager; + private eventManager = PluginEventManager.getInstance(); constructor(pluginManager: IPluginManager) { this.pluginManager = pluginManager; @@ -61,7 +63,7 @@ class ElectronPluginListener { return async (event: IpcMainEvent, ...args: any[]) => { try { await handler.call(this, event, ...args); - event.reply(PluginChannel.STATUS, { status: 'success' }); + this.eventManager.notify(); } catch (error: any) { logger.error('Error handling event:', error); event.reply(PluginChannel.STATUS, { status: 'error', message: error?.message ?? String(error) }); @@ -70,36 +72,50 @@ class ElectronPluginListener { } private async loadPlugins(event: IpcMainEvent): Promise { + event.reply(PluginChannel.STATUS, { status: 'inProgress', message: 'Plugins loading...' }); await this.pluginManager.loadPlugins(); + event.reply(PluginChannel.STATUS, { status: 'success', message: 'Plugins loaded' }); } private initializePlugins(event: IpcMainEvent): void { + event.reply(PluginChannel.STATUS, { status: 'inProgress', message: 'Plugins initializing' }); this.pluginManager.initializePlugins(); + event.reply(PluginChannel.STATUS, { status: 'success', message: 'Plugins initialized' }); } private disposePlugins(event: IpcMainEvent): void { + event.reply(PluginChannel.STATUS, { status: 'inProgress', message: 'Plugins Disposing...' }); this.pluginManager.disposePlugins(); + event.reply(PluginChannel.STATUS, { status: 'success', message: 'Plugins Disposed' }); } private async downloadPlugin(event: IpcMainEvent, config: any): Promise { + event.reply(PluginChannel.STATUS, { status: 'inProgress', message: 'Plugin Downloading...' }); await this.pluginManager.downloadPlugin(config); + event.reply(PluginChannel.STATUS, { status: 'success', message: 'Plugin Downloaded' }); } private async activatePlugin(event: IpcMainEvent, name: string): Promise { + event.reply(PluginChannel.STATUS, { status: 'inProgress', message: 'Plugin Activating...' }); await this.pluginManager.activatePlugin(name); + event.reply(PluginChannel.STATUS, { status: 'success', message: 'Plugin Activated' }); } private async deactivatePlugin(event: IpcMainEvent, name: string): Promise { + event.reply(PluginChannel.STATUS, { status: 'inProgress', message: 'Plugin Deactivating...' }); await this.pluginManager.deactivatePlugin(name); + event.reply(PluginChannel.STATUS, { status: 'success', message: 'Plugin Deactivated' }); } private async uninstallPlugin(event: IpcMainEvent, name: string): Promise { + event.reply(PluginChannel.STATUS, { status: 'inProgress', message: 'Plugin Uninstalling...' }); await this.pluginManager.uninstallPlugin(name); + event.reply(PluginChannel.STATUS, { status: 'success', message: 'Plugin Uninstalled' }); } } export async function pluginListeners(): Promise { - const pluginManager: IPluginManager = new PluginManager(); + const pluginManager: IPluginManager = PluginManager.getInstance(); const listener = new ElectronPluginListener(pluginManager); listener.registerListeners(); listener.registerHandlers(); diff --git a/packages/desktop-libs/src/lib/plugin-system/shared/interfaces/plugin-manager.interface.ts b/packages/desktop-libs/src/lib/plugin-system/shared/interfaces/plugin-manager.interface.ts index a416f1abc23..ee15a60d77e 100644 --- a/packages/desktop-libs/src/lib/plugin-system/shared/interfaces/plugin-manager.interface.ts +++ b/packages/desktop-libs/src/lib/plugin-system/shared/interfaces/plugin-manager.interface.ts @@ -1,3 +1,4 @@ +import { MenuItemConstructorOptions } from 'electron'; import { IPluginMetadata } from './plugin-metadata.interface'; export interface IPluginManager { @@ -10,4 +11,5 @@ export interface IPluginManager { uninstallPlugin(name: string): Promise; getAllPlugins(): Promise; getOnePlugin(name: string): Promise; + getMenuPlugins(): MenuItemConstructorOptions[]; } diff --git a/packages/desktop-libs/src/lib/plugin-system/shared/interfaces/plugin.interface.ts b/packages/desktop-libs/src/lib/plugin-system/shared/interfaces/plugin.interface.ts index 381bde9fab0..34bdfeef7b4 100644 --- a/packages/desktop-libs/src/lib/plugin-system/shared/interfaces/plugin.interface.ts +++ b/packages/desktop-libs/src/lib/plugin-system/shared/interfaces/plugin.interface.ts @@ -1,9 +1,10 @@ export interface IPlugin { name: string; version: string; - initialize(): void; - dispose(): void; - activate(): void; - deactivate(): void; + initialize(): Promise | void; + dispose(): Promise | void; + activate(): Promise | void; + deactivate(): Promise | void; component?(): void; + menu?: Electron.MenuItemConstructorOptions; } diff --git a/packages/desktop-ui-lib/src/lib/services/toastr-notification.service.ts b/packages/desktop-ui-lib/src/lib/services/toastr-notification.service.ts index d7ed5bf456e..48a6183ae35 100644 --- a/packages/desktop-ui-lib/src/lib/services/toastr-notification.service.ts +++ b/packages/desktop-ui-lib/src/lib/services/toastr-notification.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@angular/core'; import { NbToastrService } from '@nebular/theme'; -import { NotificationService } from './notification.service'; import { GAUZY_ENV } from '../constants'; +import { NotificationService } from './notification.service'; @Injectable({ providedIn: 'root', @@ -31,4 +31,8 @@ export class ToastrNotificationService extends NotificationService { public warn(message: string): void { this._toastrService.warning(message, this._notification.title); } + + public info(message: string): void { + this._toastrService.info(message, this._notification.title); + } } diff --git a/packages/desktop-ui-lib/src/lib/settings/plugins/component/add-plugin/add-plugin.component.ts b/packages/desktop-ui-lib/src/lib/settings/plugins/component/add-plugin/add-plugin.component.ts index 794727099a6..f12e359d3a4 100644 --- a/packages/desktop-ui-lib/src/lib/settings/plugins/component/add-plugin/add-plugin.component.ts +++ b/packages/desktop-ui-lib/src/lib/settings/plugins/component/add-plugin/add-plugin.component.ts @@ -1,4 +1,5 @@ import { Component, inject, NgZone, OnInit } from '@angular/core'; +import { distinctUntilChange } from '@gauzy/ui-core/common'; import { NbDialogRef } from '@nebular/theme'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { tap } from 'rxjs'; @@ -20,15 +21,10 @@ export class AddPluginComponent implements OnInit { ngOnInit(): void { this.pluginElectronService.status .pipe( + distinctUntilChange(), tap(({ status, message }) => this.ngZone.run(() => { - this.installing = false; - if (status === 'success') { - this.close(); - } else { - console.error(message); - this.error = message; - } + this.handleStatus({ status, message }); }) ), untilDestroyed(this) @@ -36,6 +32,25 @@ export class AddPluginComponent implements OnInit { .subscribe(); } + private handleStatus(notification: { status: string; message?: string }) { + switch (notification.status) { + case 'success': + this.installing = false; + this.close(); + break; + case 'error': + this.installing = false; + this.error = notification.message; + break; + case 'inProgress': + this.installing = true; + break; + default: + this.installing = false; + break; + } + } + public installPlugin(value: string) { if (!value) { this.error = "The server URL mustn't be empty."; diff --git a/packages/desktop-ui-lib/src/lib/settings/plugins/component/plugin-list/plugin-list.component.ts b/packages/desktop-ui-lib/src/lib/settings/plugins/component/plugin-list/plugin-list.component.ts index ee57b7ccc30..dd4ebcac936 100644 --- a/packages/desktop-ui-lib/src/lib/settings/plugins/component/plugin-list/plugin-list.component.ts +++ b/packages/desktop-ui-lib/src/lib/settings/plugins/component/plugin-list/plugin-list.component.ts @@ -5,6 +5,7 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService } from '@ngx-translate/core'; import { Angular2SmartTableComponent, Cell, LocalDataSource } from 'angular2-smart-table'; import { BehaviorSubject, concatMap, filter, from, Observable, switchMap, tap } from 'rxjs'; +import { ToastrNotificationService } from '../../../../services'; import { PluginElectronService } from '../../services/plugin-electron.service'; import { IPlugin } from '../../services/plugin-loader.service'; import { AddPluginComponent } from '../add-plugin/add-plugin.component'; @@ -20,6 +21,7 @@ import { PluginUpdateComponent } from './plugin-update/plugin-update.component'; export class PluginListComponent implements OnInit { private readonly translateService = inject(TranslateService); private readonly pluginElectronService = inject(PluginElectronService); + private readonly toastrNotificationService = inject(ToastrNotificationService); private readonly dialog = inject(NbDialogService); private readonly router = inject(Router); private readonly ngZone = inject(NgZone); @@ -93,7 +95,7 @@ export class PluginListComponent implements OnInit { private observePlugins(): void { this.pluginElectronService.status .pipe( - tap(() => (this.processing = false)), + tap((response) => this.ngZone.run(() => this.handleStatus(response))), filter((response) => response.status === 'success'), switchMap(() => from(this.pluginElectronService.plugins)), tap((plugins) => this.ngZone.run(() => (this.plugins = plugins))), @@ -110,6 +112,27 @@ export class PluginListComponent implements OnInit { .subscribe(); } + private handleStatus(notification: { status: string; message?: string }) { + switch (notification.status) { + case 'success': + this.processing = false; + this.toastrNotificationService.success(notification.message); + break; + case 'error': + this.processing = false; + this.toastrNotificationService.error(notification.message); + break; + case 'inProgress': + this.processing = true; + this.toastrNotificationService.info(notification.message); + break; + default: + this.processing = false; + this.toastrNotificationService.warn('Unexpected Status'); + break; + } + } + private loadPlugins(): void { from(this.pluginElectronService.plugins) .pipe( @@ -119,9 +142,8 @@ export class PluginListComponent implements OnInit { .subscribe(); } - public handleRowSelection(event) { - const selected = event.selected[0]; - this.plugin = selected && selected.id === this.plugin?.id ? this.plugin : selected; + public handleRowSelection({ isSelected, data }) { + this.plugin = isSelected ? data : null; } public changeStatus() {