From bc916f3a6e2812e76f32d71fc39f616550b44470 Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Sun, 12 Nov 2023 00:09:56 +0900 Subject: [PATCH] refactor(plugin): refactor plugin loader and add dynamic loading --- src/index.ts | 92 ++++-------------------- src/loader/main.ts | 128 ++++++++++++++++++++++++++++++++++ src/plugins/utils/main/css.ts | 41 ++++++----- 3 files changed, 168 insertions(+), 93 deletions(-) create mode 100644 src/loader/main.ts diff --git a/src/index.ts b/src/index.ts index 837bc4d1..ee0a8062 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,8 @@ import { pluginBuilders } from 'virtual:PluginBuilders'; import youtubeMusicCSS from './youtube-music.css?inline'; -import type { MainPlugin, PluginBaseConfig, MainPluginContext, MainPluginFactory } from './plugins/utils/builder'; +import { getLoadedAllPlugins, loadAllPlugins, registerMainPlugin } from './loader/main'; +import { MainPluginFactory, PluginBaseConfig, PluginBuilder } from './plugins/utils/builder'; // Catch errors and log them unhandled({ @@ -87,17 +88,6 @@ function onClosed() { mainWindow = null; } -export const mainPluginNames = Object.keys(mainPlugins); - -if (is.windows()) { - delete mainPlugins['touchbar']; -} else if (is.macOS()) { - delete mainPlugins['taskbar-mediacontrol']; -} else { - delete mainPlugins['touchbar']; - delete mainPlugins['taskbar-mediacontrol']; -} - ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins)); const initHook = (win: BrowserWindow) => { @@ -114,14 +104,16 @@ const initHook = (win: BrowserWindow) => { if (!isEqual) { const config = deepmerge(pluginBuilders[id as keyof PluginBuilderList].config, newPluginConfig); + const mainPlugin = getLoadedAllPlugins()[id]; + if (mainPlugin) mainPlugin.onConfigChange?.(config as PluginBaseConfig); + win.webContents.send('config-changed', id, config); } }); }); }; -const loadedPluginList: [string, MainPlugin][] = []; -async function loadPlugins(win: BrowserWindow) { +function initTheme(win: BrowserWindow) { injectCSS(win.webContents, youtubeMusicCSS); // Load user CSS const themes: string[] = config.get('options.themes'); @@ -145,68 +137,6 @@ async function loadPlugins(win: BrowserWindow) { win.webContents.openDevTools(); } }); - - - const createContext = < - Key extends keyof PluginBuilderList, - Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], - >(name: Key): MainPluginContext => ({ - getConfig: () => deepmerge(pluginBuilders[name].config, config.get(`plugins.${name}`) ?? {}) as unknown as Config, - setConfig: (newConfig) => { - config.setPartial(`plugins.${name}`, newConfig); - }, - - send: (event: string, ...args: unknown[]) => { - win.webContents.send(event, ...args); - }, - handle: (event: string, listener) => { - ipcMain.handle(event, async (_, ...args) => listener(...args as never)); - }, - on: (event: string, listener) => { - ipcMain.on(event, async (_, ...args) => listener(...args as never)); - }, - }); - - const pluginConfigs = config.plugins.getPlugins(); - for (const [pluginId, builder] of Object.entries(pluginBuilders)) { - const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList]; - - const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {}); - - if (config.enabled) { - typedBuilder.styles?.forEach((style) => { - injectCSS(win.webContents, style, () => { - console.log('[YTMusic]', `Injected CSS for "${pluginId}" plugin`); - }); - }); - - console.log('[YTMusic]', `"${pluginId}" plugin data is loaded`); - } - } - - for (const [pluginId, factory] of Object.entries(mainPlugins)) { - if (Object.hasOwn(pluginBuilders, pluginId)) { - try { - const builder = pluginBuilders[pluginId as keyof PluginBuilderList]; - const config = deepmerge(builder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {}); - - if (config.enabled) { - try { - const context = createContext(pluginId as keyof PluginBuilderList); - const plugin = await (factory as MainPluginFactory)(context); - loadedPluginList.push([pluginId, plugin]); - plugin.onLoad?.(win); - console.log('[YTMusic]', `"${pluginId}" plugin is loaded`); - } catch (error) { - console.error('[YTMusic]', `Cannot load plugin "${pluginId}"`); - console.trace(error); - } - } - } catch(err) { - console.log('[YTMusic]', `Cannot initialize "${pluginId}" plugin: ${String(err)}`); - } - } - } } async function createMainWindow() { @@ -248,7 +178,15 @@ async function createMainWindow() { autoHideMenuBar: config.get('options.hideMenu'), }); initHook(win); - await loadPlugins(win); + initTheme(win); + + Object.entries(pluginBuilders).forEach(([id, builder]) => { + const typedBuilder = builder as PluginBuilder; + const plugin = mainPlugins[id] as MainPluginFactory | undefined; + + registerMainPlugin(id, typedBuilder, plugin); + }); + await loadAllPlugins(win); if (windowPosition) { const { x: windowX, y: windowY } = windowPosition; diff --git a/src/loader/main.ts b/src/loader/main.ts new file mode 100644 index 00000000..79424623 --- /dev/null +++ b/src/loader/main.ts @@ -0,0 +1,128 @@ +import { BrowserWindow, ipcMain } from 'electron'; + +import { deepmerge } from 'deepmerge-ts'; + +import config from '../config'; +import { injectCSS } from '../plugins/utils/main'; +import { + MainPlugin, + MainPluginContext, + MainPluginFactory, + PluginBaseConfig, + PluginBuilder +} from '../plugins/utils/builder'; + +const allPluginFactoryList: Record> = {}; +const allPluginBuilders: Record> = {}; +const unregisterStyleMap: Record void)[]> = {}; +const loadedPluginMap: Record> = {}; + +const createContext = < + Key extends keyof PluginBuilderList, + Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], +>(id: Key, win: BrowserWindow): MainPluginContext => ({ + getConfig: () => deepmerge(allPluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as Config, + setConfig: (newConfig) => { + config.setPartial(`plugins.${id}`, newConfig); + }, + + send: (event: string, ...args: unknown[]) => { + win.webContents.send(event, ...args); + }, + handle: (event: string, listener) => { + ipcMain.handle(event, async (_, ...args) => listener(...args as never)); + }, + on: (event: string, listener) => { + ipcMain.on(event, async (_, ...args) => listener(...args as never)); + }, +}); + +const forceUnloadPlugin = (id: keyof PluginBuilderList, win: BrowserWindow) => { + unregisterStyleMap[id]?.forEach((unregister) => unregister()); + delete unregisterStyleMap[id]; + + loadedPluginMap[id]?.onUnload?.(win); + delete loadedPluginMap[id]; + + console.log('[YTMusic]', `"${id}" plugin is unloaded`); +}; + +export const forceLoadPlugin = async (id: keyof PluginBuilderList, win: BrowserWindow) => { + const builder = allPluginBuilders[id]; + + Promise.allSettled( + builder.styles?.map(async (style) => { + const unregister = await injectCSS(win.webContents, style); + console.log('[YTMusic]', `Injected CSS for "${id}" plugin`); + + return unregister; + }) ?? [], + ).then((result) => { + unregisterStyleMap[id] = result + .map((it) => it.status === 'fulfilled' && it.value) + .filter(Boolean); + + let isInjectSuccess = true; + result.forEach((it) => { + if (it.status === 'rejected') { + isInjectSuccess = false; + + console.log('[YTMusic]', `Cannot inject "${id}" plugin style: ${String(it.reason)}`); + } + }); + if (isInjectSuccess) console.log('[YTMusic]', `"${id}" plugin data is loaded`); + }); + + try { + const factory = allPluginFactoryList[id]; + if (!factory) return; + + const context = createContext(id, win); + const plugin = await factory(context); + loadedPluginMap[id] = plugin; + plugin.onLoad?.(win); + + console.log('[YTMusic]', `"${id}" plugin is loaded`); + } catch (err) { + console.log('[YTMusic]', `Cannot initialize "${id}" plugin: ${String(err)}`); + } +}; + +export const loadAllPlugins = async (win: BrowserWindow) => { + const pluginConfigs = config.plugins.getPlugins(); + + for (const [pluginId, builder] of Object.entries(allPluginBuilders)) { + const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList]; + + const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {}); + + if (config.enabled) { + await forceLoadPlugin(pluginId as keyof PluginBuilderList, win); + } else { + if (loadedPluginMap[pluginId as keyof PluginBuilderList]) { + forceUnloadPlugin(pluginId as keyof PluginBuilderList, win); + } + } + } +}; + +export const unloadAllPlugins = (win: BrowserWindow) => { + for (const id of Object.keys(loadedPluginMap)) { + forceUnloadPlugin(id as keyof PluginBuilderList, win); + } +}; + +export const getLoadedPlugin = (id: Key): MainPlugin | undefined => { + return loadedPluginMap[id]; +}; +export const getLoadedAllPlugins = () => { + return loadedPluginMap; +}; +export const registerMainPlugin = ( + id: string, + builder: PluginBuilder, + factory?: MainPluginFactory, +) => { + if (factory) allPluginFactoryList[id] = factory; + allPluginBuilders[id] = builder; +}; diff --git a/src/plugins/utils/main/css.ts b/src/plugins/utils/main/css.ts index c7dd40cc..11981988 100644 --- a/src/plugins/utils/main/css.ts +++ b/src/plugins/utils/main/css.ts @@ -1,33 +1,42 @@ import fs from 'node:fs'; -const cssToInject = new Map void) | undefined>(); -const cssToInjectFile = new Map void) | undefined>(); -export const injectCSS = (webContents: Electron.WebContents, css: string, cb: (() => void) | undefined = undefined) => { - if (cssToInject.size === 0 && cssToInjectFile.size === 0) { - setupCssInjection(webContents); - } +type Unregister = () => void; - cssToInject.set(css, cb); +const cssToInject = new Map void) | undefined>(); +const cssToInjectFile = new Map void) | undefined>(); +export const injectCSS = (webContents: Electron.WebContents, css: string): Promise => { + return new Promise((resolve) => { + if (cssToInject.size === 0 && cssToInjectFile.size === 0) { + setupCssInjection(webContents); + } + cssToInject.set(css, resolve); + }); }; -export const injectCSSAsFile = (webContents: Electron.WebContents, filepath: string, cb: (() => void) | undefined = undefined) => { - if (cssToInject.size === 0 && cssToInjectFile.size === 0) { - setupCssInjection(webContents); - } +export const injectCSSAsFile = (webContents: Electron.WebContents, filepath: string): Promise => { + return new Promise((resolve) => { + if (cssToInject.size === 0 && cssToInjectFile.size === 0) { + setupCssInjection(webContents); + } - cssToInjectFile.set(filepath, cb); + cssToInjectFile.set(filepath, resolve); + }); }; const setupCssInjection = (webContents: Electron.WebContents) => { webContents.on('did-finish-load', () => { cssToInject.forEach(async (callback, css) => { - await webContents.insertCSS(css); - callback?.(); + const key = await webContents.insertCSS(css); + const remove = async () => await webContents.removeInsertedCSS(key); + + callback?.(remove); }); cssToInjectFile.forEach(async (callback, filepath) => { - await webContents.insertCSS(fs.readFileSync(filepath, 'utf-8')); - callback?.(); + const key = await webContents.insertCSS(fs.readFileSync(filepath, 'utf-8')); + const remove = async () => await webContents.removeInsertedCSS(key); + + callback?.(remove); }); }); };