feat(plugin): migrating plugins to new plugin system (WIP)

Co-authored-by: JellyBrick <shlee1503@naver.com>
This commit is contained in:
Su-Yong
2023-11-10 21:32:05 +09:00
parent afe6accab8
commit e0e17cac99
18 changed files with 538 additions and 273 deletions

View File

@ -10,6 +10,9 @@ import { restart } from '../providers/app-controls';
const set = (key: string, value: unknown) => {
store.set(key, value);
};
const setPartial = (value: object) => {
store.set(value);
};
function setMenuOption(key: string, value: unknown) {
set(key, value);
@ -43,6 +46,7 @@ export default {
defaultConfig,
get,
set,
setPartial,
setMenuOption,
edit: () => store.openInEditor(),
watch(cb: Parameters<Store['onDidChange']>[1]) {

View File

@ -22,10 +22,14 @@ import { APP_PROTOCOL, handleProtocol, setupProtocolHandler } from './providers/
// eslint-disable-next-line import/order
import { mainPlugins } from 'virtual:MainPlugins';
import ambientModeMainPluginBuilder from './plugins/ambient-mode/index';
import qualityChangerMainPluginBuilder from './plugins/quality-changer/index';
import qualityChangerMainPlugin from './plugins/quality-changer/main';
import { setOptions as pipSetOptions } from './plugins/picture-in-picture/main';
import youtubeMusicCSS from './youtube-music.css';
import { MainPlugin, PluginBaseConfig, MainPluginContext } from './plugins/utils/builder';
// Catch errors and log them
unhandled({
@ -96,6 +100,34 @@ if (is.windows()) {
ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins));
const pluginBuilderList = [
['ambient-mode', ambientModeMainPluginBuilder] as const,
['quality-changer', qualityChangerMainPluginBuilder] as const,
];
const mainPluginList = [
['quality-changer', qualityChangerMainPlugin] as const,
];
const initHook = (win: BrowserWindow) => {
ipcMain.handle('get-config', (_, name: string) => config.get(`plugins.${name}` as never));
ipcMain.handle('set-config', (_, name: string, obj: object) => config.setPartial({
plugins: {
[name]: obj,
}
}));
config.watch((newValue) => {
const value = newValue as Record<string, unknown>;
const target = pluginBuilderList.find(([name]) => name in value);
if (target) {
win.webContents.send('config-changed', target[0], value[target[0]]);
console.log('config-changed', target[0], value[target[0]]);
}
});
};
const loadedPluginList: [string, MainPlugin<PluginBaseConfig>][] = [];
async function loadPlugins(win: BrowserWindow) {
injectCSS(win.webContents, youtubeMusicCSS);
// Load user CSS
@ -121,7 +153,58 @@ async function loadPlugins(win: BrowserWindow) {
}
});
const createContext = <
Key extends keyof PluginBuilderList,
Config extends PluginBaseConfig = PluginBuilderList[Key]['config'],
>(name: Key): MainPluginContext<Config> => ({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
getConfig: () => config.get(`plugins.${name}`) as unknown as Config,
setConfig: (newConfig) => {
config.setPartial({
plugins: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
[name]: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
...config.get(`plugins.${name}`),
...newConfig,
},
},
});
return Promise.resolve();
},
send: (event: string, ...args: unknown[]) => {
win.webContents.send(event, ...args);
},
handle: (event: string, listener) => {
ipcMain.handle(event, async (_, ...args) => listener(...args as never));
},
});
for (const [plugin, options] of config.plugins.getEnabled()) {
const builderTarget = pluginBuilderList.find(([name]) => name === plugin);
const mainPluginTarget = mainPluginList.find(([name]) => name === plugin);
if (mainPluginTarget) {
const mainPlugin = mainPluginTarget[1];
const context = createContext(mainPluginTarget[0]);
const plugin = await mainPlugin(context);
loadedPluginList.push([mainPluginTarget[0], plugin]);
plugin.onLoad?.(win);
}
if (builderTarget) {
const builder = builderTarget[1];
builder.styles?.forEach((style) => {
injectCSS(win.webContents, style);
});
}
try {
if (Object.hasOwn(mainPlugins, plugin)) {
console.log('Loaded plugin - ' + plugin);
@ -174,6 +257,7 @@ async function createMainWindow() {
: 'default'),
autoHideMenuBar: config.get('options.hideMenu'),
});
initHook(win);
await loadPlugins(win);
if (windowPosition) {

View File

@ -1,5 +1,5 @@
import is from 'electron-is';
import { app, BrowserWindow, clipboard, dialog, Menu } from 'electron';
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu } from 'electron';
import prompt from 'custom-electron-prompt';
import { restart } from './providers/app-controls';
@ -10,7 +10,10 @@ import promptOptions from './providers/prompt-options';
// eslint-disable-next-line import/order
import { menuPlugins as menuList } from 'virtual:MenuPlugins';
import ambientModeMenuPlugin from './plugins/ambient-mode/menu';
import { getAvailablePluginNames } from './plugins/utils/main';
import { PluginBaseConfig, PluginContext } from './plugins/utils/builder';
export type MenuTemplate = Electron.MenuItemConstructorOptions[];
@ -43,14 +46,58 @@ export const refreshMenu = (win: BrowserWindow) => {
}
};
export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate> => {
const innerRefreshMenu = () => refreshMenu(win);
const createContext = <Config extends PluginBaseConfig>(name: string): PluginContext<Config> => ({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
getConfig: () => config.get(`plugins.${name}`) as unknown as Config,
setConfig: (newConfig) => {
config.setPartial({
plugins: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
[name]: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
...config.get(`plugins.${name}`),
...newConfig,
},
},
});
return Promise.resolve();
},
});
const pluginMenus = await Promise.all(
[['ambient-mode', ambientModeMenuPlugin] as const].map(async ([id, plugin]) => {
let pluginLabel = id;
if (betaPlugins.includes(pluginLabel)) {
pluginLabel += ' [beta]';
}
if (!config.plugins.isEnabled(id)) {
return pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu);
}
const template = await plugin(createContext(id));
return {
label: pluginLabel,
submenu: [
pluginEnabledMenu(id, 'Enabled', true, innerRefreshMenu),
{ type: 'separator' },
...template,
],
} satisfies Electron.MenuItemConstructorOptions;
}),
);
return [
{
label: 'Plugins',
submenu:
getAvailablePluginNames().map((pluginName) => {
submenu: [
...getAvailablePluginNames().map((pluginName) => {
let pluginLabel = pluginName;
if (betaPlugins.includes(pluginLabel)) {
pluginLabel += ' [beta]';
@ -75,6 +122,8 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
return pluginEnabledMenu(pluginName, pluginLabel);
}),
...pluginMenus,
],
},
{
label: 'Options',
@ -402,8 +451,8 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
}
];
};
export const setApplicationMenu = (win: Electron.BrowserWindow) => {
const menuTemplate: MenuTemplate = [...mainMenuTemplate(win)];
export const setApplicationMenu = async (win: Electron.BrowserWindow) => {
const menuTemplate: MenuTemplate = [...await mainMenuTemplate(win)];
if (process.platform === 'darwin') {
const { name } = app;
menuTemplate.unshift({

View File

@ -0,0 +1,19 @@
import style from './style.css?inline';
import { createPluginBuilder } from '../utils/builder';
const builder = createPluginBuilder('album-color-theme', {
name: 'Album Color Theme',
config: {
enabled: false,
},
styles: [style],
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,9 +0,0 @@
import { BrowserWindow } from 'electron';
import style from './style.css';
import { injectCSS } from '../utils/main';
export default (win: BrowserWindow) => {
injectCSS(win.webContents, style);
};

View File

@ -1,7 +1,8 @@
import { FastAverageColor } from 'fast-average-color';
import type { ConfigType } from '../../config/dynamic';
import builder from './';
export default builder.createRenderer(() => {
function hexToHSL(H: string) {
// Convert hex to RGB first
let r = 0;
@ -62,8 +63,8 @@ function changeElementColor(element: HTMLElement | null, hue: number, saturation
}
}
export default (_: ConfigType<'album-color-theme'>) => {
// updated elements
return {
onLoad() {
const playerPage = document.querySelector<HTMLElement>('#player-page');
const navBarBackground = document.querySelector<HTMLElement>('#nav-bar-background');
const ytmusicPlayerBar = document.querySelector<HTMLElement>('ytmusic-player-bar');
@ -124,4 +125,6 @@ export default (_: ConfigType<'album-color-theme'>) => {
}
});
});
}
};
});

View File

@ -1,4 +1,4 @@
import style from './style.css';
import style from './style.css?inline';
import { createPluginBuilder } from '../utils/builder';

View File

@ -7,13 +7,16 @@ const bufferList = [1, 5, 10, 20, 30];
const blurAmountList = [0, 5, 10, 25, 50, 100, 150, 200, 500];
const opacityList = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1];
export default builder.createMenu(({ getConfig, setConfig }) => ([
export default builder.createMenu(async ({ getConfig, setConfig }) => {
const config = await getConfig();
return [
{
label: 'Smoothness transition',
submenu: interpolationTimeList.map((interpolationTime) => ({
label: `During ${interpolationTime / 1000}s`,
type: 'radio',
checked: getConfig().interpolationTime === interpolationTime,
checked: config.interpolationTime === interpolationTime,
click() {
setConfig({ interpolationTime });
},
@ -24,7 +27,7 @@ export default builder.createMenu(({ getConfig, setConfig }) => ([
submenu: qualityList.map((quality) => ({
label: `${quality} pixels`,
type: 'radio',
checked: getConfig().quality === quality,
checked: config.quality === quality,
click() {
setConfig({ quality });
},
@ -35,7 +38,7 @@ export default builder.createMenu(({ getConfig, setConfig }) => ([
submenu: sizeList.map((size) => ({
label: `${size}%`,
type: 'radio',
checked: getConfig().size === size,
checked: config.size === size,
click() {
setConfig({ size });
},
@ -46,7 +49,7 @@ export default builder.createMenu(({ getConfig, setConfig }) => ([
submenu: bufferList.map((buffer) => ({
label: `${buffer}`,
type: 'radio',
checked: getConfig().buffer === buffer,
checked: config.buffer === buffer,
click() {
setConfig({ buffer });
},
@ -57,7 +60,7 @@ export default builder.createMenu(({ getConfig, setConfig }) => ([
submenu: opacityList.map((opacity) => ({
label: `${opacity * 100}%`,
type: 'radio',
checked: getConfig().opacity === opacity,
checked: config.opacity === opacity,
click() {
setConfig({ opacity });
},
@ -68,7 +71,7 @@ export default builder.createMenu(({ getConfig, setConfig }) => ([
submenu: blurAmountList.map((blur) => ({
label: `${blur} pixels`,
type: 'radio',
checked: getConfig().blur === blur,
checked: config.blur === blur,
click() {
setConfig({ blur });
},
@ -77,9 +80,10 @@ export default builder.createMenu(({ getConfig, setConfig }) => ([
{
label: 'Using fullscreen',
type: 'checkbox',
checked: getConfig().fullscreen,
checked: config.fullscreen,
click(item) {
setConfig({ fullscreen: item.checked });
},
},
]));
];
});

View File

@ -1,7 +1,7 @@
import builder from './index';
export default builder.createRenderer(({ getConfig }) => {
const initConfigData = getConfig();
export default builder.createRenderer(async ({ getConfig }) => {
const initConfigData = await getConfig();
let interpolationTime = initConfigData.interpolationTime;
let buffer = initConfigData.buffer;
@ -26,6 +26,7 @@ export default builder.createRenderer(({ getConfig }) => {
if (!video) return null;
if (!wrapper) return null;
console.log('injectBlurVideo', songVideo, video, wrapper);
const blurCanvas = document.createElement('canvas');
blurCanvas.classList.add('html5-blur-canvas');
@ -39,6 +40,7 @@ export default builder.createRenderer(({ getConfig }) => {
if (typeof lastEffectWorkId === 'number') cancelAnimationFrame(lastEffectWorkId);
lastEffectWorkId = requestAnimationFrame(() => {
// console.log('context', context);
if (!context) return;
const width = qualityRatio;

View File

@ -0,0 +1,16 @@
import { createPluginBuilder } from '../utils/builder';
const builder = createPluginBuilder('quality-changer', {
name: 'Quality Changer',
config: {
enabled: false,
},
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,7 +1,10 @@
import { ipcMain, dialog, BrowserWindow } from 'electron';
import { dialog, BrowserWindow } from 'electron';
export default (win: BrowserWindow) => {
ipcMain.handle('qualityChanger', async (_, qualityLabels: string[], currentIndex: number) => await dialog.showMessageBox(win, {
import builder from './index';
export default builder.createMain(({ handle }) => ({
onLoad(win: BrowserWindow) {
handle('qualityChanger', async (qualityLabels: string[], currentIndex: number) => await dialog.showMessageBox(win, {
type: 'question',
buttons: qualityLabels,
defaultId: currentIndex,
@ -10,4 +13,5 @@ export default (win: BrowserWindow) => {
detail: `Current Quality: ${qualityLabels[currentIndex]}`,
cancelId: -1,
}));
};
}
}));

View File

@ -1,8 +1,15 @@
import qualitySettingsTemplate from './templates/qualitySettingsTemplate.html?raw';
import builder from './';
import { ElementFromHtml } from '../utils/renderer';
import { YoutubePlayer } from '../../types/youtube-player';
// export default () => {
// document.addEventListener('apiLoaded', setup, { once: true, passive: true });
// };
export default builder.createRenderer(({ invoke }) => {
function $(selector: string): HTMLElement | null {
return document.querySelector(selector);
}
@ -21,7 +28,8 @@ function setup(event: CustomEvent<YoutubePlayer>) {
const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality());
window.ipcRenderer.invoke('qualityChanger', api.getAvailableQualityLabels(), currentIndex).then((promise: { response: number }) => {
invoke<{ response: number }>('qualityChanger', api.getAvailableQualityLabels(), currentIndex)
.then((promise) => {
if (promise.response === -1) {
return;
}
@ -33,6 +41,10 @@ function setup(event: CustomEvent<YoutubePlayer>) {
});
}
export default () => {
return {
onLoad() {
console.log('qc');
document.addEventListener('apiLoaded', setup, { once: true, passive: true });
}
};
});

View File

@ -19,20 +19,33 @@ export type PreloadPlugin<Config extends PluginBaseConfig> = BasePlugin<Config>;
type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
};
export type PluginContext<Config extends PluginBaseConfig> = {
getConfig: () => Config;
setConfig: (config: DeepPartial<Config>) => void;
type IF<T> = (args: T) => T;
type Promisable<T> = T | Promise<T>;
send: (event: string, ...args: unknown[]) => void;
on: (event: string, listener: (...args: unknown[]) => void) => void;
export type PluginContext<Config extends PluginBaseConfig = PluginBaseConfig> = {
getConfig: () => Promise<Config>;
setConfig: (config: DeepPartial<Config>) => Promise<void>;
};
type IF<T> = (args: T) => T;
export type MainPluginContext<Config extends PluginBaseConfig = PluginBaseConfig> = PluginContext<Config> & {
send: (event: string, ...args: unknown[]) => void;
handle: <Arguments extends unknown[], Return>(event: string, listener: (...args: Arguments) => Promisable<Return>) => void;
};
export type RendererPluginContext<Config extends PluginBaseConfig = PluginBaseConfig> = PluginContext<Config> & {
invoke: <Return>(event: string, ...args: unknown[]) => Promise<Return>;
on: <Arguments extends unknown[]>(event: string, listener: (...args: Arguments) => Promisable<void>) => void;
};
export type RendererPluginFactory<Config extends PluginBaseConfig> = (context: RendererPluginContext<Config>) => Promisable<RendererPlugin<Config>>;
export type MainPluginFactory<Config extends PluginBaseConfig> = (context: MainPluginContext<Config>) => Promisable<MainPlugin<Config>>;
export type PreloadPluginFactory<Config extends PluginBaseConfig> = (context: PluginContext<Config>) => Promisable<PreloadPlugin<Config>>;
export type MenuPluginFactory<Config extends PluginBaseConfig> = (context: PluginContext<Config>) => Promisable<MenuItemConstructorOptions[]>;
export type PluginBuilder<ID extends string, Config extends PluginBaseConfig> = {
createRenderer: IF<(context: PluginContext<Config>) => RendererPlugin<Config>>;
createMain: IF<(context: PluginContext<Config>) => MainPlugin<Config>>;
createPreload: IF<(context: PluginContext<Config>) => PreloadPlugin<Config>>;
createMenu: IF<(context: PluginContext<Config>) => MenuItemConstructorOptions[]>;
createRenderer: IF<RendererPluginFactory<Config>>;
createMain: IF<MainPluginFactory<Config>>;
createPreload: IF<PreloadPluginFactory<Config>>;
createMenu: IF<MenuPluginFactory<Config>>;
id: ID;
config: Config;
@ -48,7 +61,7 @@ export type PluginBuilderOptions<Config extends PluginBaseConfig = PluginBaseCon
export const createPluginBuilder = <ID extends string, Config extends PluginBaseConfig>(
id: ID,
options: PluginBuilderOptions<Config>,
): PluginBuilder<ID, Config> => ({
): PluginBuilder<ID, Omit<Config, 'enabled'> & PluginBaseConfig> => ({
createRenderer: (plugin) => plugin,
createMain: (plugin) => plugin,
createPreload: (plugin) => plugin,

View File

@ -7,6 +7,7 @@ export const injectCSS = (webContents: Electron.WebContents, css: string, cb: ((
setupCssInjection(webContents);
}
console.log('injectCSS', css);
cssToInject.set(css, cb);
};

View File

@ -1,10 +1,13 @@
import setupSongInfo from './providers/song-info-front';
import { setupSongControls } from './providers/song-controls-front';
import { startingPages } from './providers/extracted-data';
// eslint-disable-next-line import/order
import { rendererPlugins } from 'virtual:RendererPlugins';
import { PluginBaseConfig, RendererPluginContext } from './plugins/utils/builder';
import { startingPages } from './providers/extracted-data';
import { setupSongControls } from './providers/song-controls-front';
import setupSongInfo from './providers/song-info-front';
const enabledPluginNameAndOptions = window.mainConfig.plugins.getEnabled();
let api: Element | null = null;
@ -91,8 +94,41 @@ function onApiLoaded() {
}
}
(() => {
const createContext = <
Key extends keyof PluginBuilderList,
Config extends PluginBaseConfig = PluginBuilderList[Key]['config'],
>(name: Key): RendererPluginContext<Config> => ({
getConfig: async () => {
return await window.ipcRenderer.invoke('get-config', name) as Config;
},
setConfig: async (newConfig) => {
await window.ipcRenderer.invoke('set-config', name, newConfig);
},
invoke: async <Return>(event: string, ...args: unknown[]): Promise<Return> => {
return await window.ipcRenderer.invoke(event, ...args) as Return;
},
on: (event: string, listener) => {
window.ipcRenderer.on(event, async (_, ...args) => listener(...args as never));
},
});
(async () => {
enabledPluginNameAndOptions.forEach(async ([pluginName, options]) => {
if (pluginName === 'ambient-mode') {
const builder = rendererPlugins[pluginName];
try {
const context = createContext(pluginName);
const plugin = await builder?.(context);
console.log(plugin);
plugin.onLoad?.();
} catch (error) {
console.error(`Error in plugin "${pluginName}"`);
console.trace(error);
}
}
if (Object.hasOwn(rendererPlugins, pluginName)) {
const handler = rendererPlugins[pluginName];
try {
@ -103,6 +139,22 @@ function onApiLoaded() {
}
}
});
const rendererPluginList = await Promise.all(
newPluginList.map(async ([id, plugin]) => {
const context = createContext(id);
return [id, await plugin(context)] as const;
}),
);
rendererPluginList.forEach(([, plugin]) => plugin.onLoad?.());
window.ipcRenderer.on('config-changed', (_event, id: string, newConfig) => {
const plugin = rendererPluginList.find(([pluginId]) => pluginId === id);
if (plugin) {
plugin[1].onConfigChange?.(newConfig as never);
}
});
// Inject song-info provider
setupSongInfo();

View File

@ -1,26 +1,24 @@
declare module 'virtual:MainPlugins' {
import type { BrowserWindow } from 'electron';
import type { ConfigType } from './config/dynamic';
export const mainPlugins: Record<string, (win: BrowserWindow, options: ConfigType) => Promise<void>>;
declare module 'virtual:MainPlugins' {
import type { MainPluginFactory } from './plugins/utils/builder';
export const mainPlugins: Record<string, MainPluginFactory>;
}
declare module 'virtual:MenuPlugins' {
import type { BrowserWindow } from 'electron';
import type { MenuTemplate } from './menu';
import type { ConfigType } from './config/dynamic';
import type { MenuPluginFactory } from './plugins/utils/builder';
export const menuPlugins: Record<string, (win: BrowserWindow, options: ConfigType, refreshMenu: () => void) => MenuTemplate>;
export const menuPlugins: Record<string, MenuPluginFactory>;
}
declare module 'virtual:PreloadPlugins' {
import type { ConfigType } from './config/dynamic';
import type { PreloadPluginFactory } from './plugins/utils/builder';
export const preloadPlugins: Record<string, (options: ConfigType) => Promise<void>>;
export const preloadPlugins: Record<string, PreloadPluginFactory>;
}
declare module 'virtual:RendererPlugins' {
import type { ConfigType } from './config/dynamic';
import type { RendererPluginFactory } from './plugins/utils/builder';
export const rendererPlugins: Record<string, (options: ConfigType) => Promise<void>>;
export const rendererPlugins: Record<string, RendererPluginFactory>;
}

View File

@ -29,3 +29,9 @@ declare module '*.css' {
export default css;
}
declare module '*.css?inline' {
const css: string;
export default css;
}

View File

@ -10,7 +10,14 @@ export const pluginVirtualModuleGenerator = (mode: 'main' | 'preload' | 'rendere
const plugins = globSync(`${srcPath}/plugins/*`)
.map((path) => ({ name: basename(path), path }))
.filter(({ name, path }) => !name.startsWith('utils') && existsSync(resolve(path, `${mode}.ts`)));
.filter(({ name, path }) => {
if (name.startsWith('utils')) return false;
if (path.includes('ambient-mode')) return false;
if (path.includes('quality')) return false;
return existsSync(resolve(path, `${mode}.ts`));
});
// for test !name.startsWith('ambient-mode')
let result = '';