change plugin system

This commit is contained in:
Angelos Bouklis
2023-11-26 01:17:24 +02:00
parent 10a54b9de0
commit 3ab4cd5d05
34 changed files with 1670 additions and 990 deletions

View File

@ -2,8 +2,19 @@ import path from 'node:path';
import url from 'node:url';
import fs from 'node:fs';
import { BrowserWindow, app, screen, globalShortcut, session, shell, dialog, ipcMain } from 'electron';
import enhanceWebRequest, { BetterSession } from '@jellybrick/electron-better-web-request';
import {
BrowserWindow,
app,
screen,
globalShortcut,
session,
shell,
dialog,
ipcMain,
} from 'electron';
import enhanceWebRequest, {
BetterSession,
} from '@jellybrick/electron-better-web-request';
import is from 'electron-is';
import unhandled from 'electron-unhandled';
import { autoUpdater } from 'electron-updater';
@ -12,30 +23,31 @@ import { parse } from 'node-html-parser';
import { deepmerge } from 'deepmerge-ts';
import { deepEqual } from 'fast-equals';
import { mainPlugins } from 'virtual:MainPlugins';
import { pluginBuilders } from 'virtual:PluginBuilders';
import { mainPlugins } from 'virtual:plugins';
import config from './config';
import config from '@/config';
import { refreshMenu, setApplicationMenu } from './menu';
import { fileExists, injectCSS, injectCSSAsFile } from './plugins/utils/main';
import { isTesting } from './utils/testing';
import { setUpTray } from './tray';
import { setupSongInfo } from './providers/song-info';
import { restart, setupAppControls } from './providers/app-controls';
import { APP_PROTOCOL, handleProtocol, setupProtocolHandler } from './providers/protocol-handler';
import { refreshMenu, setApplicationMenu } from '@/menu';
import { fileExists, injectCSS, injectCSSAsFile } from '@/plugins/utils/main';
import { isTesting } from '@/utils/testing';
import { setUpTray } from '@/tray';
import { setupSongInfo } from '@/providers/song-info';
import { restart, setupAppControls } from '@/providers/app-controls';
import {
APP_PROTOCOL,
handleProtocol,
setupProtocolHandler,
} from '@/providers/protocol-handler';
import youtubeMusicCSS from './youtube-music.css?inline';
import youtubeMusicCSS from '@/youtube-music.css?inline';
import {
forceLoadMainPlugin,
forceUnloadMainPlugin,
getAllLoadedMainPlugins,
loadAllMainPlugins,
registerMainPlugin
} from './loader/main';
import { MainPluginFactory, PluginBaseConfig, PluginBuilder } from './plugins/utils/builder';
} from '@/loader/main';
import { PluginBaseConfig } from '@/plugins/utils/builder';
// Catch errors and log them
unhandled({
@ -57,7 +69,10 @@ if (!gotTheLock) {
// SharedArrayBuffer: Required for downloader (@ffmpeg/core-mt)
// OverlayScrollbar: Required for overlay scrollbars
app.commandLine.appendSwitch('enable-features', 'OverlayScrollbar,SharedArrayBuffer');
app.commandLine.appendSwitch(
'enable-features',
'OverlayScrollbar,SharedArrayBuffer',
);
if (config.get('options.disableHardwareAcceleration')) {
if (is.dev()) {
console.log('Disabling hardware acceleration');
@ -95,42 +110,58 @@ function onClosed() {
ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins));
const initHook = (win: BrowserWindow) => {
ipcMain.handle('get-config', (_, id: keyof PluginBuilderList) => deepmerge(pluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as PluginBuilderList[typeof id]['config']);
ipcMain.handle('set-config', (_, name: string, obj: object) => config.setPartial(`plugins.${name}`, obj));
ipcMain.handle(
'get-config',
(_, id: keyof PluginBuilderList) =>
deepmerge(
mainPlugins[id].config,
config.get(`plugins.${id}`) ?? {},
) as PluginBuilderList[typeof id]['config'],
);
ipcMain.handle('set-config', (_, name: string, obj: object) =>
config.setPartial(`plugins.${name}`, obj),
);
config.watch((newValue, oldValue) => {
const newPluginConfigList = (newValue?.plugins ?? {}) as Record<string, unknown>;
const oldPluginConfigList = (oldValue?.plugins ?? {}) as Record<string, unknown>;
const newPluginConfigList = (newValue?.plugins ?? {}) as Record<
string,
unknown
>;
const oldPluginConfigList = (oldValue?.plugins ?? {}) as Record<
string,
unknown
>;
Object.entries(newPluginConfigList).forEach(([id, newPluginConfig]) => {
const isEqual = deepEqual(oldPluginConfigList[id], newPluginConfig);
if (!isEqual) {
const oldConfig = oldPluginConfigList[id] as PluginBaseConfig;
const config = deepmerge(pluginBuilders[id as keyof PluginBuilderList].config, newPluginConfig) as PluginBaseConfig;
const config = deepmerge(
mainPlugins[id].config,
newPluginConfig,
) as PluginBaseConfig;
if (config.enabled !== oldConfig?.enabled) {
if (config.enabled) {
win.webContents.send('plugin:enable', id);
ipcMain.emit('plugin:enable', id);
forceLoadMainPlugin(id as keyof PluginBuilderList, win);
forceLoadMainPlugin(id, win);
} else {
win.webContents.send('plugin:unload', id);
ipcMain.emit('plugin:unload', id);
forceUnloadMainPlugin(id as keyof PluginBuilderList, win);
forceUnloadMainPlugin(id, win);
}
if (pluginBuilders[id as keyof PluginBuilderList].restartNeeded) {
showNeedToRestartDialog(id as keyof PluginBuilderList);
if (mainPlugins[id].restartNeeded) {
showNeedToRestartDialog(id);
}
}
const mainPlugin = getAllLoadedMainPlugins()[id];
if (mainPlugin) {
if (config.enabled) {
mainPlugin.onConfigChange?.(config);
}
}
@ -140,14 +171,15 @@ const initHook = (win: BrowserWindow) => {
});
};
const showNeedToRestartDialog = (id: keyof PluginBuilderList) => {
const builder = pluginBuilders[id];
const showNeedToRestartDialog = (id: string) => {
const plugin = mainPlugins[id];
const dialogOptions: Electron.MessageBoxOptions = {
type: 'info',
buttons: ['Restart Now', 'Later'],
title: 'Restart Required',
message: `"${builder.name ?? builder.id}" needs to restart`,
detail: `"${builder.name ?? builder.id}" plugin requires a restart to take effect`,
message: `"${plugin.name ?? id}" needs to restart`,
detail: `"${plugin.name ?? id}" plugin requires a restart to take effect`,
defaultId: 0,
cancelId: 1,
};
@ -186,7 +218,10 @@ function initTheme(win: BrowserWindow) {
injectCSSAsFile(win.webContents, cssFile);
},
() => {
console.warn('[YTMusic]', `CSS file "${cssFile}" does not exist, ignoring`);
console.warn(
'[YTMusic]',
`CSS file "${cssFile}" does not exist, ignoring`,
);
},
);
}
@ -224,46 +259,43 @@ async function createMainWindow() {
...(isTesting()
? undefined
: {
// Sandbox is only enabled in tests for now
// See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts
sandbox: false,
}),
// Sandbox is only enabled in tests for now
// See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts
sandbox: false,
}),
},
frame: !is.macOS() && !useInlineMenu,
titleBarOverlay: defaultTitleBarOverlayOptions,
titleBarStyle: useInlineMenu
? 'hidden'
: (is.macOS()
? 'hiddenInset'
: 'default'),
: is.macOS()
? 'hiddenInset'
: 'default',
autoHideMenuBar: config.get('options.hideMenu'),
});
initHook(win);
initTheme(win);
Object.entries(pluginBuilders).forEach(([id, builder]) => {
const typedBuilder = builder as PluginBuilder<string, PluginBaseConfig>;
const plugin = mainPlugins[id] as MainPluginFactory<PluginBaseConfig> | undefined;
registerMainPlugin(id, typedBuilder, plugin);
});
await loadAllMainPlugins(win);
if (windowPosition) {
const { x: windowX, y: windowY } = windowPosition;
const winSize = win.getSize();
const displaySize
= screen.getDisplayNearestPoint(windowPosition).bounds;
const displaySize = screen.getDisplayNearestPoint(windowPosition).bounds;
if (
windowX + winSize[0] < displaySize.x - 8
|| windowX - winSize[0] > displaySize.x + displaySize.width
|| windowY < displaySize.y - 8
|| windowY > displaySize.y + displaySize.height
windowX + winSize[0] < displaySize.x - 8 ||
windowX - winSize[0] > displaySize.x + displaySize.width ||
windowY < displaySize.y - 8 ||
windowY > displaySize.y + displaySize.height
) {
// Window is offscreen
if (is.dev()) {
console.log(
`Window tried to render offscreen, windowSize=${String(winSize)}, displaySize=${String(displaySize)}, position=${String(windowPosition)}`,
`Window tried to render offscreen, windowSize=${String(
winSize,
)}, displaySize=${String(displaySize)}, position=${String(
windowPosition,
)}`,
);
}
} else {
@ -316,7 +348,11 @@ async function createMainWindow() {
const savedTimeouts: Record<string, NodeJS.Timeout | undefined> = {};
function lateSave(key: string, value: unknown, fn: (key: string, value: unknown) => void = config.set) {
function lateSave(
key: string,
value: unknown,
fn: (key: string, value: unknown) => void = config.set,
) {
if (savedTimeouts[key]) {
clearTimeout(savedTimeouts[key]);
}
@ -343,7 +379,10 @@ async function createMainWindow() {
if (useInlineMenu) {
win.setTitleBarOverlay({
...defaultTitleBarOverlayOptions,
height: Math.floor(defaultTitleBarOverlayOptions.height! * win.webContents.getZoomFactor()),
height: Math.floor(
defaultTitleBarOverlayOptions.height! *
win.webContents.getZoomFactor(),
),
});
}
@ -365,14 +404,25 @@ async function createMainWindow() {
`);
} else {
const rendererPath = path.join(__dirname, '..', 'renderer');
const indexHTML = parse(fs.readFileSync(path.join(rendererPath, 'index.html'), 'utf-8'));
const indexHTML = parse(
fs.readFileSync(path.join(rendererPath, 'index.html'), 'utf-8'),
);
const scriptSrc = indexHTML.querySelector('script')!;
const scriptPath = path.join(rendererPath, scriptSrc.getAttribute('src')!);
const scriptPath = path.join(
rendererPath,
scriptSrc.getAttribute('src')!,
);
const scriptString = fs.readFileSync(scriptPath, 'utf-8');
await win.webContents.executeJavaScriptInIsolatedWorld(0, [{
code: scriptString + ';0',
url: url.pathToFileURL(scriptPath).toString(),
}], true);
await win.webContents.executeJavaScriptInIsolatedWorld(
0,
[
{
code: scriptString + ';0',
url: url.pathToFileURL(scriptPath).toString(),
},
],
true,
);
}
});
@ -387,21 +437,26 @@ app.once('browser-window-created', (event, win) => {
const originalUserAgent = win.webContents.userAgent;
const userAgents = {
mac: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 12.1; rv:95.0) Gecko/20100101 Firefox/95.0',
windows: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0',
windows:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0',
linux: 'Mozilla/5.0 (Linux x86_64; rv:95.0) Gecko/20100101 Firefox/95.0',
};
const updatedUserAgent
= is.macOS() ? userAgents.mac
: (is.windows() ? userAgents.windows
: userAgents.linux);
const updatedUserAgent = is.macOS()
? userAgents.mac
: is.windows()
? userAgents.windows
: userAgents.linux;
win.webContents.userAgent = updatedUserAgent;
app.userAgentFallback = updatedUserAgent;
win.webContents.session.webRequest.onBeforeSendHeaders((details, cb) => {
// This will only happen if login failed, and "retry" was pressed
if (win.webContents.getURL().startsWith('https://accounts.google.com') && details.url.startsWith('https://accounts.google.com')) {
if (
win.webContents.getURL().startsWith('https://accounts.google.com') &&
details.url.startsWith('https://accounts.google.com')
) {
details.requestHeaders['User-Agent'] = originalUserAgent;
}
@ -412,33 +467,41 @@ app.once('browser-window-created', (event, win) => {
setupSongInfo(win);
setupAppControls();
win.webContents.on('did-fail-load', (
_event,
errorCode,
errorDescription,
validatedURL,
isMainFrame,
frameProcessId,
frameRoutingId,
) => {
const log = JSON.stringify({
error: 'did-fail-load',
win.webContents.on(
'did-fail-load',
(
_event,
errorCode,
errorDescription,
validatedURL,
isMainFrame,
frameProcessId,
frameRoutingId,
}, null, '\t');
if (is.dev()) {
console.log(log);
}
) => {
const log = JSON.stringify(
{
error: 'did-fail-load',
errorCode,
errorDescription,
validatedURL,
isMainFrame,
frameProcessId,
frameRoutingId,
},
null,
'\t',
);
if (is.dev()) {
console.log(log);
}
if (errorCode !== -3) { // -3 is a false positive
win.webContents.send('log', log);
win.webContents.loadFile(path.join(__dirname, 'error.html'));
}
});
if (errorCode !== -3) {
// -3 is a false positive
win.webContents.send('log', log);
win.webContents.loadFile(path.join(__dirname, 'error.html'));
}
},
);
win.webContents.on('will-prevent-unload', (event) => {
event.preventDefault();
@ -484,17 +547,29 @@ app.on('ready', async () => {
const appLocation = process.execPath;
const appData = app.getPath('appData');
// Check shortcut validity if not in dev mode / running portable app
if (!is.dev() && !appLocation.startsWith(path.join(appData, '..', 'Local', 'Temp'))) {
const shortcutPath = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'YouTube Music.lnk');
try { // Check if shortcut is registered and valid
if (
!is.dev() &&
!appLocation.startsWith(path.join(appData, '..', 'Local', 'Temp'))
) {
const shortcutPath = path.join(
appData,
'Microsoft',
'Windows',
'Start Menu',
'Programs',
'YouTube Music.lnk',
);
try {
// Check if shortcut is registered and valid
const shortcutDetails = shell.readShortcutLink(shortcutPath); // Throw error if doesn't exist yet
if (
shortcutDetails.target !== appLocation
|| shortcutDetails.appUserModelId !== appID
shortcutDetails.target !== appLocation ||
shortcutDetails.appUserModelId !== appID
) {
throw 'needUpdate';
}
} catch (error) { // If not valid -> Register shortcut
} catch (error) {
// If not valid -> Register shortcut
shell.writeShortcutLink(
shortcutPath,
error === 'needUpdate' ? 'update' : 'create',
@ -556,8 +631,8 @@ app.on('ready', async () => {
clearTimeout(updateTimeout);
}, 2000);
autoUpdater.on('update-available', () => {
const downloadLink
= 'https://github.com/th-ch/youtube-music/releases/latest';
const downloadLink =
'https://github.com/th-ch/youtube-music/releases/latest';
const dialogOptions: Electron.MessageBoxOptions = {
type: 'info',
buttons: ['OK', 'Download', 'Disable updates'],
@ -597,8 +672,10 @@ app.on('ready', async () => {
if (config.get('options.hideMenu') && !config.get('options.hideMenuWarned')) {
dialog.showMessageBox(mainWindow, {
type: 'info', title: 'Hide Menu Enabled',
message: "Menu is hidden, use 'Alt' to show it (or 'Escape' if using in-app-menu)",
type: 'info',
title: 'Hide Menu Enabled',
message:
"Menu is hidden, use 'Alt' to show it (or 'Escape' if using in-app-menu)",
});
config.set('options.hideMenuWarned', true);
}
@ -624,31 +701,36 @@ app.on('ready', async () => {
}
});
function showUnresponsiveDialog(win: BrowserWindow, details: Electron.RenderProcessGoneDetails) {
function showUnresponsiveDialog(
win: BrowserWindow,
details: Electron.RenderProcessGoneDetails,
) {
if (details) {
console.log('Unresponsive Error!\n' + JSON.stringify(details, null, '\t'));
}
dialog.showMessageBox(win, {
type: 'error',
title: 'Window Unresponsive',
message: 'The Application is Unresponsive',
detail: 'We are sorry for the inconvenience! please choose what to do:',
buttons: ['Wait', 'Relaunch', 'Quit'],
cancelId: 0,
}).then((result) => {
switch (result.response) {
case 1: {
restart();
break;
}
dialog
.showMessageBox(win, {
type: 'error',
title: 'Window Unresponsive',
message: 'The Application is Unresponsive',
detail: 'We are sorry for the inconvenience! please choose what to do:',
buttons: ['Wait', 'Relaunch', 'Quit'],
cancelId: 0,
})
.then((result) => {
switch (result.response) {
case 1: {
restart();
break;
}
case 2: {
app.quit();
break;
case 2: {
app.quit();
break;
}
}
}
});
});
}
function removeContentSecurityPolicy(
@ -671,18 +753,21 @@ function removeContentSecurityPolicy(
});
// When multiple listeners are defined, apply them all
betterSession.webRequest.setResolver('onHeadersReceived', async (listeners) => {
return listeners.reduce(
async (accumulator, listener) => {
const acc = await accumulator;
if (acc.cancel) {
return acc;
}
betterSession.webRequest.setResolver(
'onHeadersReceived',
async (listeners) => {
return listeners.reduce(
async (accumulator, listener) => {
const acc = await accumulator;
if (acc.cancel) {
return acc;
}
const result = await listener.apply();
return { ...accumulator, ...result };
},
Promise.resolve({ cancel: false }),
);
});
const result = await listener.apply();
return { ...accumulator, ...result };
},
Promise.resolve({ cancel: false }),
);
},
);
}