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

@ -1,8 +1,8 @@
module.exports = {
extends: [
'plugin:import/typescript',
'eslint:recommended',
'plugin:import/recommended',
'plugin:import/typescript',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
@ -13,30 +13,51 @@ module.exports = {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
ecmaVersion: 'latest'
ecmaVersion: 'latest',
},
rules: {
'arrow-parens': ['error', 'always'],
'object-curly-spacing': ['error', 'always'],
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/no-misused-promises': ['off', { checksVoidReturn: false }],
'@typescript-eslint/no-misused-promises': [
'off',
{ checksVoidReturn: false },
],
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
"@typescript-eslint/no-non-null-assertion": "off",
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'import/first': 'error',
'import/newline-after-import': 'error',
'import/no-default-export': 'off',
'import/no-duplicates': 'error',
'import/no-unresolved': ['error', { ignore: ['^virtual:', '\\?inline$', '\\?raw$', '\\?asset&asarUnpack', '^youtubei.js$'] }],
'import/no-unresolved': [
'error',
{
ignore: [
'^virtual:',
'\\?inline$',
'\\?raw$',
'\\?asset&asarUnpack',
'^youtubei.js$',
],
},
],
'import/order': [
'error',
{
'groups': ['builtin', 'external', ['internal', 'index', 'sibling'], 'parent', 'type'],
groups: [
'builtin',
'external',
['internal', 'index', 'sibling'],
'parent',
'type',
],
'newlines-between': 'always-and-inside-groups',
'alphabetize': {order: 'ignore', caseInsensitive: false}
}
alphabetize: { order: 'ignore', caseInsensitive: false },
},
],
'import/prefer-default-export': 'off',
'camelcase': ['error', {properties: 'never'}],
camelcase: ['error', { properties: 'never' }],
'class-methods-use-this': 'off',
'lines-around-comment': [
'error',
@ -49,17 +70,21 @@ module.exports = {
],
'max-len': 'off',
'no-mixed-operators': 'error',
'no-multi-spaces': ['error', {ignoreEOLComments: true}],
'no-multi-spaces': ['error', { ignoreEOLComments: true }],
'no-tabs': 'error',
'no-void': 'error',
'no-empty': 'off',
'prefer-promise-reject-errors': 'off',
'quotes': ['error', 'single', {
avoidEscape: true,
allowTemplateLiterals: false,
}],
quotes: [
'error',
'single',
{
avoidEscape: true,
allowTemplateLiterals: false,
},
],
'quote-props': ['error', 'consistent'],
'semi': ['error', 'always'],
semi: ['error', 'always'],
},
env: {
browser: true,
@ -67,4 +92,15 @@ module.exports = {
es6: true,
},
ignorePatterns: ['dist', 'node_modules'],
root: true,
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
'import/resolver': {
typescript: {
alwaysTryTypes: true,
},
},
},
};

1
.gitignore vendored
View File

@ -12,3 +12,4 @@ electron-builder.yml
!.yarn/releases
!.yarn/sdks
!.yarn/versions
.vite-inspect

View File

@ -1,8 +1,12 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig, defineViteConfig } from 'electron-vite';
import builtinModules from 'builtin-modules';
import viteResolve from 'vite-plugin-resolve';
import Inspect from 'vite-plugin-inspect';
import { pluginVirtualModuleGenerator } from './vite-plugins/plugin-virtual-module-generator';
import { pluginVirtualModuleGenerator } from './vite-plugins/plugin-importer';
import pluginLoader from './vite-plugins/plugin-loader';
import type { UserConfig } from 'vite';
@ -10,10 +14,9 @@ export default defineConfig({
main: defineViteConfig(({ mode }) => {
const commonConfig: UserConfig = {
plugins: [
pluginLoader('backend'),
viteResolve({
'virtual:PluginBuilders': pluginVirtualModuleGenerator('index'),
'virtual:MainPlugins': pluginVirtualModuleGenerator('main'),
'virtual:MenuPlugins': pluginVirtualModuleGenerator('menu'),
'virtual:plugins': pluginVirtualModuleGenerator('main'),
}),
],
publicDir: 'assets',
@ -31,9 +34,18 @@ export default defineConfig({
input: './src/index.ts',
},
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@assets': fileURLToPath(new URL('./assets', import.meta.url)),
},
},
};
if (mode === 'development') {
commonConfig.plugins?.push(
Inspect({ build: true, outputDir: '.vite-inspect/backend' }),
);
return commonConfig;
}
@ -49,9 +61,9 @@ export default defineConfig({
preload: defineViteConfig(({ mode }) => {
const commonConfig: UserConfig = {
plugins: [
pluginLoader('preload'),
viteResolve({
'virtual:PluginBuilders': pluginVirtualModuleGenerator('index'),
'virtual:PreloadPlugins': pluginVirtualModuleGenerator('preload'),
'virtual:plugins': pluginVirtualModuleGenerator('preload'),
}),
],
build: {
@ -66,11 +78,20 @@ export default defineConfig({
rollupOptions: {
external: ['electron', 'custom-electron-prompt', ...builtinModules],
input: './src/preload.ts',
}
},
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@assets': fileURLToPath(new URL('./assets', import.meta.url)),
},
},
};
if (mode === 'development') {
commonConfig.plugins?.push(
Inspect({ build: true, outputDir: '.vite-inspect/preload' }),
);
return commonConfig;
}
@ -86,9 +107,9 @@ export default defineConfig({
renderer: defineViteConfig(({ mode }) => {
const commonConfig: UserConfig = {
plugins: [
pluginLoader('renderer'),
viteResolve({
'virtual:PluginBuilders': pluginVirtualModuleGenerator('index'),
'virtual:RendererPlugins': pluginVirtualModuleGenerator('renderer'),
'virtual:plugins': pluginVirtualModuleGenerator('renderer'),
}),
],
root: './src/',
@ -107,9 +128,18 @@ export default defineConfig({
input: './src/index.html',
},
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@assets': fileURLToPath(new URL('./assets', import.meta.url)),
},
},
};
if (mode === 'development') {
commonConfig.plugins?.push(
Inspect({ build: true, outputDir: '.vite-inspect/renderer' }),
);
return commonConfig;
}

View File

@ -94,11 +94,12 @@
"test": "playwright test",
"test:debug": "cross-env DEBUG=pw:*,-pw:test:protocol playwright test",
"build": "electron-vite build",
"vite:inspect": "yarpm-pnpm run clean && electron-vite build --mode development && yarpm-pnpm exec serve .vite-inspect",
"start": "electron-vite preview",
"start:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 yarpm-pnpm run start",
"dev": "electron-vite dev --watch",
"dev:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 yarpm-pnpm run dev",
"clean": "del-cli dist && del-cli pack",
"clean": "del-cli dist && del-cli pack && del-cli .vite-inspect",
"dist": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --win --mac --linux -p never",
"dist:linux": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --linux -p never",
"dist:mac": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --mac dmg:x64 -p never",
@ -136,6 +137,7 @@
"dependencies": {
"@cliqz/adblocker-electron": "1.26.11",
"@cliqz/adblocker-electron-preload": "1.26.11",
"@electron-toolkit/tsconfig": "^1.0.1",
"@ffmpeg.wasm/core-mt": "0.12.0",
"@ffmpeg.wasm/main": "0.12.0",
"@foobar404/wave": "2.0.4",
@ -155,6 +157,7 @@
"electron-store": "8.1.0",
"electron-unhandled": "4.0.1",
"electron-updater": "6.1.4",
"eslint-import-resolver-typescript": "^3.6.1",
"fast-average-color": "9.4.0",
"fast-equals": "^5.0.1",
"filenamify": "6.0.0",
@ -164,7 +167,9 @@
"keyboardevents-areequal": "0.2.2",
"node-html-parser": "6.1.11",
"node-id3": "0.2.6",
"serve": "^14.2.1",
"simple-youtube-age-restriction-bypass": "git+https://github.com/organization/Simple-YouTube-Age-Restriction-Bypass.git#v2.5.8",
"ts-morph": "^20.0.0",
"vudio": "2.1.1",
"x11": "2.3.0",
"youtubei.js": "7.0.0"
@ -194,6 +199,7 @@
"typescript": "5.2.2",
"utf-8-validate": "6.0.3",
"vite": "4.5.0",
"vite-plugin-inspect": "^0.7.42",
"vite-plugin-resolve": "2.5.1",
"ws": "8.14.2",
"yarpm": "1.2.0"

608
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,9 @@ import Store from 'electron-store';
import { deepmerge } from 'deepmerge-ts';
import defaultConfig from './defaults';
import plugins from './plugins';
import store from './store';
import plugins from './plugins';
import { restart } from '../providers/app-controls';
@ -15,7 +16,7 @@ const setPartial = (key: string, value: object) => {
store.set(key, newValue);
};
function setMenuOption(key: string, value: unknown) {
function setMenuOption(key: string, value: unknown) {
set(key, value);
if (store.get('options.restartOnConfigChanges')) {
restart();
@ -24,24 +25,55 @@ function setMenuOption(key: string, value: unknown) {
// MAGIC OF TYPESCRIPT
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]]
type Join<K, P> = K extends string | number ?
P extends string | number ?
`${K}${'' extends P ? '' : '.'}${P}`
: never : never;
type Paths<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
{ [K in keyof T]-?: K extends string | number ?
`${K}` | Join<K, Paths<T[K], Prev[D]>>
type Prev = [
never,
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
...0[],
];
type Join<K, P> = K extends string | number
? P extends string | number
? `${K}${'' extends P ? '' : '.'}${P}`
: never
}[keyof T] : ''
: never;
type Paths<T, D extends number = 10> = [D] extends [never]
? never
: T extends object
? {
[K in keyof T]-?: K extends string | number
? `${K}` | Join<K, Paths<T[K], Prev[D]>>
: never;
}[keyof T]
: '';
type SplitKey<K> = K extends `${infer A}.${infer B}` ? [A, B] : [K, string];
type PathValue<T, K extends string> =
SplitKey<K> extends [infer A extends keyof T, infer B extends string]
? PathValue<T[A], B>
: T;
const get = <Key extends Paths<typeof defaultConfig>>(key: Key) => store.get(key) as PathValue<typeof defaultConfig, typeof key>;
type PathValue<T, K extends string> = SplitKey<K> extends [
infer A extends keyof T,
infer B extends string,
]
? PathValue<T[A], B>
: T;
const get = <Key extends Paths<typeof defaultConfig>>(key: Key) =>
store.get(key) as PathValue<typeof defaultConfig, typeof key>;
export default {
defaultConfig,

View File

@ -1,15 +1,18 @@
import Store from 'electron-store';
import Conf from 'conf';
import { pluginBuilders } from 'virtual:PluginBuilders';
import { allPlugins } from 'virtual:plugins';
import defaults from './defaults';
import { DefaultPresetList, type Preset } from '../plugins/downloader/types';
const setDefaultPluginOptions = (store: Conf<Record<string, unknown>>, plugin: keyof typeof pluginBuilders) => {
const setDefaultPluginOptions = (
store: Conf<Record<string, unknown>>,
plugin: string,
) => {
if (!store.get(`plugins.${plugin}`)) {
store.set(`plugins.${plugin}`, pluginBuilders[plugin].config);
store.set(`plugins.${plugin}`, allPlugins[plugin].config);
}
};
@ -22,19 +25,24 @@ const migrations = {
}
},
'>=2.1.0'(store: Conf<Record<string, unknown>>) {
const originalPreset = store.get('plugins.downloader.preset') as string | undefined;
const originalPreset = store.get('plugins.downloader.preset') as
| string
| undefined;
if (originalPreset) {
if (originalPreset !== 'opus') {
store.set('plugins.downloader.selectedPreset', 'Custom');
store.set('plugins.downloader.customPresetSetting', {
extension: 'mp3',
ffmpegArgs: store.get('plugins.downloader.ffmpegArgs') as string[] ?? DefaultPresetList['mp3 (256kbps)'].ffmpegArgs,
ffmpegArgs:
(store.get('plugins.downloader.ffmpegArgs') as string[]) ??
DefaultPresetList['mp3 (256kbps)'].ffmpegArgs,
} satisfies Preset);
} else {
store.set('plugins.downloader.selectedPreset', 'Source');
store.set('plugins.downloader.customPresetSetting', {
extension: null,
ffmpegArgs: store.get('plugins.downloader.ffmpegArgs') as string[] ?? [],
ffmpegArgs:
(store.get('plugins.downloader.ffmpegArgs') as string[]) ?? [],
} satisfies Preset);
}
store.delete('plugins.downloader.preset');
@ -47,7 +55,7 @@ const migrations = {
if (store.get('plugins.notifications.toastStyle') === undefined) {
const pluginOptions = store.get('plugins.notifications') || {};
store.set('plugins.notifications', {
...pluginBuilders.notifications.config,
...allPlugins.notifications.config,
...pluginOptions,
});
}
@ -82,10 +90,14 @@ const migrations = {
}
},
'>=1.12.0'(store: Conf<Record<string, unknown>>) {
const options = store.get('plugins.shortcuts') as Record<string, {
action: string;
shortcut: unknown;
}[] | Record<string, unknown>>;
const options = store.get('plugins.shortcuts') as Record<
string,
| {
action: string;
shortcut: unknown;
}[]
| Record<string, unknown>
>;
let updated = false;
for (const optionType of ['global', 'local']) {
if (Array.isArray(options[optionType])) {
@ -151,12 +163,13 @@ const migrations = {
export default new Store({
defaults: {
...defaults,
plugins: Object
.entries(pluginBuilders)
.reduce((prev, [id, builder]) => ({
plugins: Object.entries(allPlugins).reduce(
(prev, [id, plugin]) => ({
...prev,
[id]: (builder as PluginBuilderList[keyof PluginBuilderList]).config,
}), {}),
[id]: plugin.config,
}),
{},
),
},
clearInvalidConfig: false,
migrations,

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 }),
);
},
);
}

View File

@ -1,27 +1,24 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
import { BrowserWindow, ipcMain } from 'electron';
import { deepmerge } from 'deepmerge-ts';
import { mainPlugins } from 'virtual:plugins';
import config from '../config';
import { injectCSS } from '../plugins/utils/main';
import {
MainPlugin,
MainPluginContext,
MainPluginFactory,
PluginBaseConfig,
PluginBuilder
} from '../plugins/utils/builder';
import { PluginDef } from '@/types/plugins';
import { BackendContext } from '@/types/contexts';
import config from '@/config';
import { startPlugin, stopPlugin } from '@/utils';
const allPluginFactoryList: Record<string, MainPluginFactory<PluginBaseConfig>> = {};
const allPluginBuilders: Record<string, PluginBuilder<string, PluginBaseConfig>> = {};
const unregisterStyleMap: Record<string, (() => void)[]> = {};
const loadedPluginMap: Record<string, MainPlugin<PluginBaseConfig>> = {};
const loadedPluginMap: Record<string, PluginDef> = {};
const createContext = <
Key extends keyof PluginBuilderList,
Config extends PluginBaseConfig = PluginBuilderList[Key]['config'],
>(id: Key, win: BrowserWindow): MainPluginContext<Config> => ({
getConfig: () => deepmerge(allPluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as Config,
const createContext = (id: string, win: BrowserWindow): BackendContext => ({
getConfig: () =>
// @ts-expect-error ts dum dum
deepmerge(
mainPlugins[id].config,
config.get(`plugins.${id}`) ?? { enabled: false },
),
setConfig: (newConfig) => {
config.setPartial(`plugins.${id}`, newConfig);
},
@ -29,81 +26,95 @@ const createContext = <
send: (event: string, ...args: unknown[]) => {
win.webContents.send(event, ...args);
},
// @ts-expect-error ts dum dum
handle: (event: string, listener) => {
ipcMain.handle(event, async (_, ...args) => listener(...args as never));
// @ts-expect-error ts dum dum
ipcMain.handle(event, (_, ...args) => listener(...(args as never)));
},
// @ts-expect-error ts dum dum
on: (event: string, listener) => {
ipcMain.on(event, async (_, ...args) => listener(...args as never));
// @ts-expect-error ts dum dum
ipcMain.on(event, (_, ...args) => listener(...(args as never)));
},
});
export const forceUnloadMainPlugin = (id: keyof PluginBuilderList, win: BrowserWindow) => {
unregisterStyleMap[id]?.forEach((unregister) => unregister());
delete unregisterStyleMap[id];
export const forceUnloadMainPlugin = async (
id: string,
win: BrowserWindow,
): Promise<void> => {
const plugin = loadedPluginMap[id]!;
if (!plugin) return;
loadedPluginMap[id]?.onUnload?.(win);
delete loadedPluginMap[id];
return new Promise<void>((resolve, reject) => {
try {
const hasStopped = stopPlugin(id, plugin, {
ctx: 'backend',
context: createContext(id, win),
});
if (!hasStopped) {
console.log(
'[YTMusic]',
`Cannot unload "${id}" plugin: no stop function`,
);
reject();
return;
}
console.log('[YTMusic]', `"${id}" plugin is unloaded`);
delete loadedPluginMap[id];
console.log('[YTMusic]', `"${id}" plugin is unloaded`);
resolve();
} catch (err) {
console.log('[YTMusic]', `Cannot unload "${id}" plugin: ${String(err)}`);
reject(err);
}
});
};
export const forceLoadMainPlugin = async (id: keyof PluginBuilderList, win: BrowserWindow) => {
const builder = allPluginBuilders[id];
export const forceLoadMainPlugin = async (
id: string,
win: BrowserWindow,
): Promise<void> => {
const plugin = mainPlugins[id];
if (!plugin.backend) return;
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)}`);
return new Promise<void>((resolve, reject) => {
try {
const hasStarted = startPlugin(id, plugin, {
ctx: 'backend',
context: createContext(id, win),
});
if (!hasStarted) {
console.log('[YTMusic]', `Cannot load "${id}" plugin`);
reject();
return;
}
});
if (isInjectSuccess) console.log('[YTMusic]', `"${id}" plugin data is loaded`);
loadedPluginMap[id] = plugin;
resolve();
} catch (err) {
console.log(
'[YTMusic]',
`Cannot initialize "${id}" plugin: ${String(err)}`,
);
reject(err);
}
});
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 loadAllMainPlugins = async (win: BrowserWindow) => {
const pluginConfigs = config.plugins.getPlugins();
const queue: Promise<void>[] = [];
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] ?? {});
for (const [plugin, pluginDef] of Object.entries(mainPlugins)) {
const config = deepmerge(pluginDef.config, pluginConfigs[plugin] ?? {});
if (config.enabled) {
await forceLoadMainPlugin(pluginId as keyof PluginBuilderList, win);
} else {
if (loadedPluginMap[pluginId as keyof PluginBuilderList]) {
forceUnloadMainPlugin(pluginId as keyof PluginBuilderList, win);
}
queue.push(forceLoadMainPlugin(plugin, win));
} else if (loadedPluginMap[plugin]) {
queue.push(forceUnloadMainPlugin(plugin, win));
}
}
await Promise.all(queue);
};
export const unloadAllMainPlugins = (win: BrowserWindow) => {
@ -112,17 +123,10 @@ export const unloadAllMainPlugins = (win: BrowserWindow) => {
}
};
export const getLoadedMainPlugin = <Key extends keyof PluginBuilderList>(id: Key): MainPlugin<PluginBuilderList[Key]['config']> | undefined => {
export const getLoadedMainPlugin = (id: string): PluginDef | undefined => {
return loadedPluginMap[id];
};
export const getAllLoadedMainPlugins = () => {
return loadedPluginMap;
};
export const registerMainPlugin = (
id: string,
builder: PluginBuilder<string, PluginBaseConfig>,
factory?: MainPluginFactory<PluginBaseConfig>,
) => {
if (factory) allPluginFactoryList[id] = factory;
allPluginBuilders[id] = builder;
};

View File

@ -1,26 +1,21 @@
import { deepmerge } from 'deepmerge-ts';
import { allPlugins } from 'virtual:plugins';
import { BrowserWindow, MenuItemConstructorOptions } from 'electron';
import { MenuPluginContext, MenuPluginFactory, PluginBaseConfig, PluginBuilder } from '../plugins/utils/builder';
import config from '../config';
import { setApplicationMenu } from '../menu';
import { MenuContext } from '@/types/contexts';
import type { BrowserWindow, MenuItemConstructorOptions } from 'electron';
import config from '@/config';
import { setApplicationMenu } from '@/menu';
const allPluginFactoryList: Record<string, MenuPluginFactory<PluginBaseConfig>> = {};
const allPluginBuilders: Record<string, PluginBuilder<string, PluginBaseConfig>> = {};
const menuTemplateMap: Record<string, MenuItemConstructorOptions[]> = {};
const createContext = <
Key extends keyof PluginBuilderList,
Config extends PluginBaseConfig = PluginBuilderList[Key]['config'],
>(id: Key, win: BrowserWindow): MenuPluginContext<Config> => ({
getConfig: () => deepmerge(allPluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as Config,
const createContext = (id: string, win: BrowserWindow): MenuContext => ({
getConfig: () => config.plugins.getOptions(id),
setConfig: (newConfig) => {
config.setPartial(`plugins.${id}`, newConfig);
},
window: win,
refresh: async () => {
await setApplicationMenu(win);
refresh: () => {
setApplicationMenu(win);
if (config.plugins.isEnabled('in-app-menu')) {
win.webContents.send('refresh-in-app-menu');
@ -28,45 +23,39 @@ const createContext = <
},
});
export const forceLoadMenuPlugin = async (id: keyof PluginBuilderList, win: BrowserWindow) => {
export const forceLoadMenuPlugin = (id: string, win: BrowserWindow) => {
try {
const factory = allPluginFactoryList[id];
if (!factory) return;
const plugin = allPlugins[id];
if (!plugin) return;
const context = createContext(id, win);
menuTemplateMap[id] = await factory(context);
const menu = plugin.menu?.(createContext(id, win));
if (menu) menuTemplateMap[id] = menu;
else return;
console.log('[YTMusic]', `"${id}" plugin is loaded`);
console.log('[YTMusic]', `Successfully loaded '${id}::menu'`);
} catch (err) {
console.log('[YTMusic]', `Cannot initialize "${id}" plugin: ${String(err)}`);
console.log('[YTMusic]', `Cannot initialize '${id}::menu': ${String(err)}`);
}
};
export const loadAllMenuPlugins = async (win: BrowserWindow) => {
export const loadAllMenuPlugins = (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] ?? {});
for (const [pluginId, pluginDef] of Object.entries(allPlugins)) {
const config = deepmerge(pluginDef.config, pluginConfigs[pluginId] ?? {});
if (config.enabled) {
await forceLoadMenuPlugin(pluginId as keyof PluginBuilderList, win);
forceLoadMenuPlugin(pluginId, win);
}
}
};
export const getMenuTemplate = <Key extends keyof PluginBuilderList>(id: Key): MenuItemConstructorOptions[] | undefined => {
export const getMenuTemplate = (
id: string,
): MenuItemConstructorOptions[] | undefined => {
return menuTemplateMap[id];
};
export const getAllMenuTemplate = () => {
return menuTemplateMap;
};
export const registerMenuPlugin = (
id: string,
builder: PluginBuilder<string, PluginBaseConfig>,
factory?: MenuPluginFactory<PluginBaseConfig>,
) => {
if (factory) allPluginFactoryList[id] = factory;
allPluginBuilders[id] = builder;
};

View File

@ -1,68 +1,64 @@
import { deepmerge } from 'deepmerge-ts';
import { preloadPlugins } from 'virtual:plugins';
import {
PluginBaseConfig,
PluginBuilder,
PreloadPlugin,
PluginContext,
PreloadPluginFactory
} from '../plugins/utils/builder';
import config from '../config';
import { type PluginDef } from '@/types/plugins';
import { type PreloadContext } from '@/types/contexts';
import { startPlugin, stopPlugin } from '@/utils';
const allPluginFactoryList: Record<string, PreloadPluginFactory<PluginBaseConfig>> = {};
const allPluginBuilders: Record<string, PluginBuilder<string, PluginBaseConfig>> = {};
const unregisterStyleMap: Record<string, (() => void)[]> = {};
const loadedPluginMap: Record<string, PreloadPlugin<PluginBaseConfig>> = {};
import config from '@/config';
const createContext = <
Key extends keyof PluginBuilderList,
Config extends PluginBaseConfig = PluginBuilderList[Key]['config'],
>(id: Key): PluginContext<Config> => ({
getConfig: () => deepmerge(allPluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as Config,
const loadedPluginMap: Record<string, PluginDef> = {};
const createContext = (id: string): PreloadContext => ({
getConfig: () => config.plugins.getOptions(id),
setConfig: (newConfig) => {
config.setPartial(`plugins.${id}`, newConfig);
},
});
export const forceUnloadPreloadPlugin = (id: keyof PluginBuilderList) => {
unregisterStyleMap[id]?.forEach((unregister) => unregister());
delete unregisterStyleMap[id];
loadedPluginMap[id]?.onUnload?.();
delete loadedPluginMap[id];
export const forceUnloadPreloadPlugin = (id: string) => {
const hasStopped = stopPlugin(id, loadedPluginMap[id], {
ctx: 'preload',
context: createContext(id),
});
if (!hasStopped) {
console.log('[YTMusic]', `Cannot stop "${id}" plugin`);
return;
}
console.log('[YTMusic]', `"${id}" plugin is unloaded`);
};
export const forceLoadPreloadPlugin = async (id: keyof PluginBuilderList) => {
export const forceLoadPreloadPlugin = (id: string) => {
try {
const factory = allPluginFactoryList[id];
if (!factory) return;
const plugin = preloadPlugins[id];
if (!plugin) return;
const context = createContext(id);
const plugin = await factory(context);
loadedPluginMap[id] = plugin;
plugin.onLoad?.();
const hasStarted = startPlugin(id, plugin, {
ctx: 'preload',
context: createContext(id),
});
if (hasStarted) loadedPluginMap[id] = plugin;
console.log('[YTMusic]', `"${id}" plugin is loaded`);
} catch (err) {
console.log('[YTMusic]', `Cannot initialize "${id}" plugin: ${String(err)}`);
console.log(
'[YTMusic]',
`Cannot initialize "${id}" plugin: ${String(err)}`,
);
}
};
export const loadAllPreloadPlugins = async () => {
export const loadAllPreloadPlugins = () => {
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] ?? {});
for (const [pluginId, pluginDef] of Object.entries(preloadPlugins)) {
const config = deepmerge(pluginDef.config, pluginConfigs[pluginId] ?? {});
if (config.enabled) {
await forceLoadPreloadPlugin(pluginId as keyof PluginBuilderList);
forceLoadPreloadPlugin(pluginId);
} else {
if (loadedPluginMap[pluginId as keyof PluginBuilderList]) {
forceUnloadPreloadPlugin(pluginId as keyof PluginBuilderList);
if (loadedPluginMap[pluginId]) {
forceUnloadPreloadPlugin(pluginId);
}
}
}
@ -74,17 +70,10 @@ export const unloadAllPreloadPlugins = () => {
}
};
export const getLoadedPreloadPlugin = <Key extends keyof PluginBuilderList>(id: Key): PreloadPlugin<PluginBuilderList[Key]['config']> | undefined => {
export const getLoadedPreloadPlugin = (id: string): PluginDef | undefined => {
return loadedPluginMap[id];
};
export const getAllLoadedPreloadPlugins = () => {
return loadedPluginMap;
};
export const registerPreloadPlugin = (
id: string,
builder: PluginBuilder<string, PluginBaseConfig>,
factory?: PreloadPluginFactory<PluginBaseConfig>,
) => {
if (factory) allPluginFactoryList[id] = factory;
allPluginBuilders[id] = builder;
};

View File

@ -1,75 +1,75 @@
import { deepmerge } from 'deepmerge-ts';
import {
PluginBaseConfig, PluginBuilder,
RendererPlugin,
RendererPluginContext,
RendererPluginFactory
} from '../plugins/utils/builder';
import { rendererPlugins } from 'virtual:plugins';
import { RendererContext } from '@/types/contexts';
import { PluginDef } from '@/types/plugins';
import { startPlugin, stopPlugin } from '@/utils';
const allPluginFactoryList: Record<string, RendererPluginFactory<PluginBaseConfig>> = {};
const allPluginBuilders: Record<string, PluginBuilder<string, PluginBaseConfig>> = {};
const unregisterStyleMap: Record<string, (() => void)[]> = {};
const loadedPluginMap: Record<string, RendererPlugin<PluginBaseConfig>> = {};
const loadedPluginMap: Record<string, PluginDef> = {};
const createContext = <
Key extends keyof PluginBuilderList,
Config extends PluginBaseConfig = PluginBuilderList[Key]['config'],
>(id: Key): RendererPluginContext<Config> => ({
getConfig: async () => {
return await window.ipcRenderer.invoke('get-config', id) as Config;
},
const createContext = (id: string): RendererContext => ({
getConfig: () => window.mainConfig.plugins.getOptions(id),
setConfig: async (newConfig) => {
await window.ipcRenderer.invoke('set-config', id, 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));
},
});
export const forceUnloadRendererPlugin = (id: keyof PluginBuilderList) => {
export const forceUnloadRendererPlugin = (id: string) => {
unregisterStyleMap[id]?.forEach((unregister) => unregister());
delete unregisterStyleMap[id];
loadedPluginMap[id]?.onUnload?.();
delete unregisterStyleMap[id];
delete loadedPluginMap[id];
const plugin = rendererPlugins[id];
if (!plugin) return;
stopPlugin(id, plugin, { ctx: 'renderer', context: createContext(id) });
if (plugin.renderer?.stylesheet)
document.querySelector(`style#plugin-${id}`)?.remove();
console.log('[YTMusic]', `"${id}" plugin is unloaded`);
};
export const forceLoadRendererPlugin = async (id: keyof PluginBuilderList) => {
try {
const factory = allPluginFactoryList[id];
if (!factory) return;
export const forceLoadRendererPlugin = (id: string) => {
const plugin = rendererPlugins[id];
if (!plugin) return;
const context = createContext(id);
const plugin = await factory(context);
const hasEvaled = startPlugin(id, plugin, {
ctx: 'renderer',
context: createContext(id),
});
if (hasEvaled || plugin.renderer?.stylesheet) {
loadedPluginMap[id] = plugin;
plugin.onLoad?.();
console.log('[YTMusic]', `"${id}" plugin is loaded`);
} catch (err) {
console.log('[YTMusic]', `Cannot initialize "${id}" plugin: ${String(err)}`);
if (plugin.renderer?.stylesheet)
document.head.appendChild(
Object.assign(document.createElement('style'), {
id: `plugin-${id}`,
innerHTML: plugin.renderer?.stylesheet ?? '',
}),
);
if (!hasEvaled) console.log('[YTMusic]', `"${id}" plugin is loaded`);
} else {
console.log('[YTMusic]', `Cannot initialize "${id}" plugin`);
}
};
export const loadAllRendererPlugins = async () => {
export const loadAllRendererPlugins = () => {
const pluginConfigs = window.mainConfig.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] ?? {});
for (const [pluginId, pluginDef] of Object.entries(rendererPlugins)) {
const config = deepmerge(pluginDef.config, pluginConfigs[pluginId] ?? {});
if (config.enabled) {
await forceLoadRendererPlugin(pluginId as keyof PluginBuilderList);
forceLoadRendererPlugin(pluginId);
} else {
if (loadedPluginMap[pluginId as keyof PluginBuilderList]) {
forceUnloadRendererPlugin(pluginId as keyof PluginBuilderList);
if (loadedPluginMap[pluginId]) {
forceUnloadRendererPlugin(pluginId);
}
}
}
@ -77,21 +77,14 @@ export const loadAllRendererPlugins = async () => {
export const unloadAllRendererPlugins = () => {
for (const id of Object.keys(loadedPluginMap)) {
forceUnloadRendererPlugin(id as keyof PluginBuilderList);
forceUnloadRendererPlugin(id);
}
};
export const getLoadedRendererPlugin = <Key extends keyof PluginBuilderList>(id: Key): RendererPlugin<PluginBuilderList[Key]['config']> | undefined => {
export const getLoadedRendererPlugin = (id: string): PluginDef | undefined => {
return loadedPluginMap[id];
};
export const getAllLoadedRendererPlugins = () => {
return loadedPluginMap;
};
export const registerRendererPlugin = (
id: string,
builder: PluginBuilder<string, PluginBaseConfig>,
factory?: RendererPluginFactory<PluginBaseConfig>,
) => {
if (factory) allPluginFactoryList[id] = factory;
allPluginBuilders[id] = builder;
};

View File

@ -1,5 +1,12 @@
import is from 'electron-is';
import { app, BrowserWindow, clipboard, dialog, Menu } from 'electron';
import {
app,
BrowserWindow,
clipboard,
dialog,
Menu,
MenuItem,
} from 'electron';
import prompt from 'custom-electron-prompt';
import { restart } from './providers/app-controls';
@ -8,24 +15,22 @@ import { startingPages } from './providers/extracted-data';
import promptOptions from './providers/prompt-options';
/* eslint-disable import/order */
import { menuPlugins as menuList } from 'virtual:MenuPlugins';
import { pluginBuilders } from 'virtual:PluginBuilders';
import { allPlugins } from 'virtual:plugins';
/* eslint-enable import/order */
import { getAvailablePluginNames } from './plugins/utils/main';
import {
MenuPluginFactory,
PluginBaseConfig,
PluginBuilder
} from './plugins/utils/builder';
import { getAllMenuTemplate, loadAllMenuPlugins, registerMenuPlugin } from './loader/menu';
import { getAllMenuTemplate, loadAllMenuPlugins } from './loader/menu';
export type MenuTemplate = Electron.MenuItemConstructorOptions[];
// True only if in-app-menu was loaded on launch
const inAppMenuActive = config.plugins.isEnabled('in-app-menu');
const pluginEnabledMenu = (plugin: string, label = '', hasSubmenu = false, refreshMenu: (() => void ) | undefined = undefined): Electron.MenuItemConstructorOptions => ({
const pluginEnabledMenu = (
plugin: string,
label = '',
hasSubmenu = false,
refreshMenu: (() => void) | undefined = undefined,
): Electron.MenuItemConstructorOptions => ({
label: label || plugin,
type: 'checkbox',
checked: config.plugins.isEnabled(plugin),
@ -49,47 +54,42 @@ export const refreshMenu = (win: BrowserWindow) => {
}
};
Object.entries(pluginBuilders).forEach(([id, builder]) => {
const typedBuilder = builder as PluginBuilder<string, PluginBaseConfig>;
const plugin = menuList[id] as MenuPluginFactory<PluginBaseConfig> | undefined;
registerMenuPlugin(id, typedBuilder, plugin);
});
export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate> => {
export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
const innerRefreshMenu = () => refreshMenu(win);
await loadAllMenuPlugins(win);
loadAllMenuPlugins(win);
const menuResult = Object.entries(getAllMenuTemplate()).map(([id, template]) => {
const pluginLabel = (pluginBuilders[id as keyof PluginBuilderList])?.name ?? id;
const menuResult = Object.entries(getAllMenuTemplate()).map(
([id, template]) => {
const pluginLabel = allPlugins[id]?.name ?? id;
if (!config.plugins.isEnabled(id)) {
return [
id,
pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu),
] as const;
}
if (!config.plugins.isEnabled(id)) {
return [
id,
pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu),
{
label: pluginLabel,
submenu: [
pluginEnabledMenu(id, 'Enabled', true, innerRefreshMenu),
{ type: 'separator' },
...template,
],
} satisfies Electron.MenuItemConstructorOptions,
] as const;
}
},
);
return [
id,
{
label: pluginLabel,
submenu: [
pluginEnabledMenu(id, 'Enabled', true, innerRefreshMenu),
{ type: 'separator' },
...template,
],
} satisfies Electron.MenuItemConstructorOptions
] as const;
});
const availablePlugins = getAvailablePluginNames();
const availablePlugins = Object.keys(allPlugins);
const pluginMenus = availablePlugins.map((id) => {
const predefinedTemplate = menuResult.find((it) => it[0] === id);
if (predefinedTemplate) return predefinedTemplate[1];
const pluginLabel = pluginBuilders[id as keyof PluginBuilderList]?.name ?? id;
const pluginLabel = allPlugins[id]?.name ?? id;
return pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu);
});
@ -106,7 +106,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
label: 'Auto-update',
type: 'checkbox',
checked: config.get('options.autoUpdates'),
click(item) {
click(item: MenuItem) {
config.setMenuOption('options.autoUpdates', item.checked);
},
},
@ -114,21 +114,22 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
label: 'Resume last song when app starts',
type: 'checkbox',
checked: config.get('options.resumeOnStart'),
click(item) {
click(item: MenuItem) {
config.setMenuOption('options.resumeOnStart', item.checked);
},
},
{
label: 'Starting page',
submenu: (() => {
const subMenuArray: Electron.MenuItemConstructorOptions[] = Object.keys(startingPages).map((name) => ({
label: name,
type: 'radio',
checked: config.get('options.startingPage') === name,
click() {
config.set('options.startingPage', name);
},
}));
const subMenuArray: Electron.MenuItemConstructorOptions[] =
Object.keys(startingPages).map((name) => ({
label: name,
type: 'radio',
checked: config.get('options.startingPage') === name,
click() {
config.set('options.startingPage', name);
},
}));
subMenuArray.unshift({
label: 'Unset',
type: 'radio',
@ -147,8 +148,11 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
label: 'Remove upgrade button',
type: 'checkbox',
checked: config.get('options.removeUpgradeButton'),
click(item) {
config.setMenuOption('options.removeUpgradeButton', item.checked);
click(item: MenuItem) {
config.setMenuOption(
'options.removeUpgradeButton',
item.checked,
);
},
},
{
@ -213,7 +217,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
label: 'Single instance lock',
type: 'checkbox',
checked: true,
click(item) {
click(item: MenuItem) {
if (!item.checked && app.hasSingleInstanceLock()) {
app.releaseSingleInstanceLock();
} else if (item.checked && !app.hasSingleInstanceLock()) {
@ -225,43 +229,45 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
label: 'Always on top',
type: 'checkbox',
checked: config.get('options.alwaysOnTop'),
click(item) {
click(item: MenuItem) {
config.setMenuOption('options.alwaysOnTop', item.checked);
win.setAlwaysOnTop(item.checked);
},
},
...(is.windows() || is.linux()
...((is.windows() || is.linux()
? [
{
label: 'Hide menu',
type: 'checkbox',
checked: config.get('options.hideMenu'),
click(item) {
config.setMenuOption('options.hideMenu', item.checked);
if (item.checked && !config.get('options.hideMenuWarned')) {
dialog.showMessageBox(win, {
type: 'info', title: 'Hide Menu Enabled',
message: 'Menu will be hidden on next launch, use [Alt] to show it (or backtick [`] if using in-app-menu)',
});
}
{
label: 'Hide menu',
type: 'checkbox',
checked: config.get('options.hideMenu'),
click(item) {
config.setMenuOption('options.hideMenu', item.checked);
if (item.checked && !config.get('options.hideMenuWarned')) {
dialog.showMessageBox(win, {
type: 'info',
title: 'Hide Menu Enabled',
message:
'Menu will be hidden on next launch, use [Alt] to show it (or backtick [`] if using in-app-menu)',
});
}
},
},
},
]
: []) satisfies Electron.MenuItemConstructorOptions[],
...(is.windows() || is.macOS()
]
: []) satisfies Electron.MenuItemConstructorOptions[]),
...((is.windows() || is.macOS()
? // Only works on Win/Mac
// https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows
[
{
label: 'Start at login',
type: 'checkbox',
checked: config.get('options.startAtLogin'),
click(item) {
config.setMenuOption('options.startAtLogin', item.checked);
// https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows
[
{
label: 'Start at login',
type: 'checkbox',
checked: config.get('options.startAtLogin'),
click(item) {
config.setMenuOption('options.startAtLogin', item.checked);
},
},
},
]
: []) satisfies Electron.MenuItemConstructorOptions[],
]
: []) satisfies Electron.MenuItemConstructorOptions[]),
{
label: 'Tray',
submenu: [
@ -277,7 +283,8 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
{
label: 'Enabled + app visible',
type: 'radio',
checked: config.get('options.tray') && config.get('options.appVisible'),
checked:
config.get('options.tray') && config.get('options.appVisible'),
click() {
config.setMenuOption('options.tray', true);
config.setMenuOption('options.appVisible', true);
@ -286,7 +293,8 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
{
label: 'Enabled + app hidden',
type: 'radio',
checked: config.get('options.tray') && !config.get('options.appVisible'),
checked:
config.get('options.tray') && !config.get('options.appVisible'),
click() {
config.setMenuOption('options.tray', true);
config.setMenuOption('options.appVisible', false);
@ -297,8 +305,11 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
label: 'Play/Pause on click',
type: 'checkbox',
checked: config.get('options.trayClickPlayPause'),
click(item) {
config.setMenuOption('options.trayClickPlayPause', item.checked);
click(item: MenuItem) {
config.setMenuOption(
'options.trayClickPlayPause',
item.checked,
);
},
},
],
@ -310,7 +321,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
{
label: 'Set Proxy',
type: 'normal',
async click(item) {
async click(item: MenuItem) {
await setProxy(item, win);
},
},
@ -318,7 +329,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
label: 'Override useragent',
type: 'checkbox',
checked: config.get('options.overrideUserAgent'),
click(item) {
click(item: MenuItem) {
config.setMenuOption('options.overrideUserAgent', item.checked);
},
},
@ -326,40 +337,46 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
label: 'Disable hardware acceleration',
type: 'checkbox',
checked: config.get('options.disableHardwareAcceleration'),
click(item) {
config.setMenuOption('options.disableHardwareAcceleration', item.checked);
click(item: MenuItem) {
config.setMenuOption(
'options.disableHardwareAcceleration',
item.checked,
);
},
},
{
label: 'Restart on config changes',
type: 'checkbox',
checked: config.get('options.restartOnConfigChanges'),
click(item) {
config.setMenuOption('options.restartOnConfigChanges', item.checked);
click(item: MenuItem) {
config.setMenuOption(
'options.restartOnConfigChanges',
item.checked,
);
},
},
{
label: 'Reset App cache when app starts',
type: 'checkbox',
checked: config.get('options.autoResetAppCache'),
click(item) {
click(item: MenuItem) {
config.setMenuOption('options.autoResetAppCache', item.checked);
},
},
{ type: 'separator' },
is.macOS()
? {
label: 'Toggle DevTools',
// Cannot use "toggleDevTools" role in macOS
click() {
const { webContents } = win;
if (webContents.isDevToolsOpened()) {
webContents.closeDevTools();
} else {
webContents.openDevTools();
}
},
}
label: 'Toggle DevTools',
// Cannot use "toggleDevTools" role in macOS
click() {
const { webContents } = win;
if (webContents.isDevToolsOpened()) {
webContents.closeDevTools();
} else {
webContents.openDevTools();
}
},
}
: { role: 'toggleDevTools' },
{
label: 'Edit config.json',
@ -377,8 +394,14 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
{ role: 'reload' },
{ role: 'forceReload' },
{ type: 'separator' },
{ role: 'zoomIn', accelerator: process.platform === 'darwin' ? 'Cmd+I' : 'Ctrl+I' },
{ role: 'zoomOut', accelerator: process.platform === 'darwin' ? 'Cmd+O' : 'Ctrl+O' },
{
role: 'zoomIn',
accelerator: process.platform === 'darwin' ? 'Cmd+I' : 'Ctrl+I',
},
{
role: 'zoomOut',
accelerator: process.platform === 'darwin' ? 'Cmd+O' : 'Ctrl+O',
},
{ role: 'resetZoom' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
@ -419,14 +442,12 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
},
{
label: 'About',
submenu: [
{ role: 'about' },
],
}
submenu: [{ role: 'about' }],
},
];
};
export const setApplicationMenu = async (win: Electron.BrowserWindow) => {
const menuTemplate: MenuTemplate = [...await mainMenuTemplate(win)];
export const setApplicationMenu = (win: Electron.BrowserWindow) => {
const menuTemplate: MenuTemplate = [...mainMenuTemplate(win)];
if (process.platform === 'darwin') {
const { name } = app;
menuTemplate.unshift({
@ -455,23 +476,27 @@ export const setApplicationMenu = async (win: Electron.BrowserWindow) => {
};
async function setProxy(item: Electron.MenuItem, win: BrowserWindow) {
const output = await prompt({
title: 'Set Proxy',
label: 'Enter Proxy Address: (leave empty to disable)',
value: config.get('options.proxy'),
type: 'input',
inputAttrs: {
type: 'url',
placeholder: "Example: 'socks5://127.0.0.1:9999",
const output = await prompt(
{
title: 'Set Proxy',
label: 'Enter Proxy Address: (leave empty to disable)',
value: config.get('options.proxy'),
type: 'input',
inputAttrs: {
type: 'url',
placeholder: "Example: 'socks5://127.0.0.1:9999",
},
width: 450,
...promptOptions(),
},
width: 450,
...promptOptions(),
}, win);
win,
);
if (typeof output === 'string') {
config.setMenuOption('options.proxy', output);
item.checked = output !== '';
} else { // User pressed cancel
} else {
// User pressed cancel
item.checked = !item.checked; // Reset checkbox
}
}

View File

@ -0,0 +1,25 @@
import { createPlugin } from '@/utils';
export default createPlugin({
name: 'Audio Compressor',
description: '',
renderer() {
document.addEventListener(
'audioCanPlay',
({ detail: { audioSource, audioContext } }) => {
const compressor = audioContext.createDynamicsCompressor();
compressor.threshold.value = -50;
compressor.ratio.value = 12;
compressor.knee.value = 40;
compressor.attack.value = 0;
compressor.release.value = 0.25;
audioSource.connect(compressor);
compressor.connect(audioContext.destination);
},
{ once: true, passive: true },
);
},
});

View File

@ -1,17 +0,0 @@
import { createPluginBuilder } from '../utils/builder';
const builder = createPluginBuilder('audio-compressor', {
name: 'Audio Compressor',
restartNeeded: false,
config: {
enabled: false,
},
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,24 +0,0 @@
import builder from './index';
export default builder.createRenderer(() => {
return {
onLoad() {
document.addEventListener('audioCanPlay', (e) => {
const { audioContext } = e.detail;
const compressor = audioContext.createDynamicsCompressor();
compressor.threshold.value = -50;
compressor.ratio.value = 12;
compressor.knee.value = 40;
compressor.attack.value = 0;
compressor.release.value = 0.25;
e.detail.audioSource.connect(compressor);
compressor.connect(audioContext.destination);
}, {
once: true, // Only create the audio compressor once, not on each video
passive: true,
});
}
};
});

View File

@ -1,20 +1,7 @@
import { createPlugin } from '@/utils';
import style from './style.css?inline';
import { createPluginBuilder } from '../utils/builder';
const builder = createPluginBuilder('blur-nav-bar', {
export default createPlugin({
name: 'Blur Navigation Bar',
restartNeeded: true,
config: {
enabled: false,
},
styles: [style],
renderer: { stylesheet: style },
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,17 +1,9 @@
import { createPluginBuilder } from '../utils/builder';
import { createPlugin } from '@/utils';
const builder = createPluginBuilder('bypass-age-restrictions', {
export default createPlugin({
name: 'Bypass Age Restrictions',
restartNeeded: true,
config: {
enabled: false,
},
// See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#userscript
renderer: () => import('simple-youtube-age-restriction-bypass'),
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,8 +0,0 @@
import builder from './index';
export default builder.createRenderer(() => ({
async onLoad() {
// See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#userscript
await import('simple-youtube-age-restriction-bypass');
},
}));

View File

@ -1,20 +1,54 @@
import { createPluginBuilder } from '../utils/builder';
import prompt from 'custom-electron-prompt';
const builder = createPluginBuilder('captions-selector', {
import promptOptions from '@/providers/prompt-options';
import { createPlugin } from '@/utils';
export default createPlugin({
name: 'Captions Selector',
restartNeeded: false,
config: {
enabled: false,
disableCaptions: false,
autoload: false,
lastCaptionsCode: '',
},
menu({ getConfig, setConfig }) {
const config = getConfig();
return [
{
label: 'Automatically select last used caption',
type: 'checkbox',
checked: config.autoload as boolean,
click(item) {
setConfig({ autoload: item.checked });
},
},
{
label: 'No captions by default',
type: 'checkbox',
checked: config.disableCaptions as boolean,
click(item) {
setConfig({ disableCaptions: item.checked });
},
},
];
},
backend({ ipc: { handle }, win }) {
handle(
'captionsSelector',
async (_, captionLabels: Record<string, string>, currentIndex: string) =>
await prompt(
{
title: 'Choose Caption',
label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`,
type: 'select',
value: currentIndex,
selectOptions: captionLabels,
resizable: true,
...promptOptions(),
},
win,
),
);
},
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,22 +0,0 @@
import prompt from 'custom-electron-prompt';
import builder from './index';
import promptOptions from '../../providers/prompt-options';
export default builder.createMain(({ handle }) => ({
onLoad(window) {
handle('captionsSelector', async (_, captionLabels: Record<string, string>, currentIndex: string) => await prompt(
{
title: 'Choose Caption',
label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`,
type: 'select',
value: currentIndex,
selectOptions: captionLabels,
resizable: true,
...promptOptions(),
},
window,
));
}
}));

View File

@ -1,26 +0,0 @@
import builder from './index';
import type { MenuTemplate } from '../../menu';
export default builder.createMenu(async ({ getConfig, setConfig }): Promise<MenuTemplate> => {
const config = await getConfig();
return [
{
label: 'Automatically select last used caption',
type: 'checkbox',
checked: config.autoload,
click(item) {
setConfig({ autoload: item.checked });
},
},
{
label: 'No captions by default',
type: 'checkbox',
checked: config.disableCaptions,
click(item) {
setConfig({ disableCaptions: item.checked });
},
},
];
});

View File

@ -1,5 +1,4 @@
export * from './css';
export * from './fs';
export * from './plugin';
export * from './types';
export * from './fetch';

View File

@ -1,18 +0,0 @@
import is from 'electron-is';
import { pluginBuilders } from 'virtual:PluginBuilders';
export const getAvailablePluginNames = () => {
return Object.keys(pluginBuilders)
.sort()
.filter((id) => {
if (is.windows() && id === 'touchbar') {
return false;
} else if (is.macOS() && id === 'taskbar-mediacontrol') {
return false;
} else if (is.linux() && (id === 'taskbar-mediacontrol' || id === 'touchbar')) {
return false;
}
return true;
});
};

View File

@ -1,29 +1,14 @@
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
import is from 'electron-is';
import { pluginBuilders } from 'virtual:PluginBuilders';
import { preloadPlugins } from 'virtual:PreloadPlugins';
import config from './config';
import {
PluginBaseConfig,
PluginBuilder,
PreloadPluginFactory
} from './plugins/utils/builder';
import {
forceLoadPreloadPlugin,
forceUnloadPreloadPlugin,
loadAllPreloadPlugins,
registerPreloadPlugin
} from './loader/preload';
Object.entries(pluginBuilders).forEach(([id, builder]) => {
const typedBuilder = builder as PluginBuilder<string, PluginBaseConfig>;
const plugin = preloadPlugins[id] as PreloadPluginFactory<PluginBaseConfig> | undefined;
registerPreloadPlugin(id, typedBuilder, plugin);
});
loadAllPreloadPlugins();
ipcRenderer.on('plugin:unload', (_, id: keyof PluginBuilderList) => {
@ -33,19 +18,34 @@ ipcRenderer.on('plugin:enable', (_, id: keyof PluginBuilderList) => {
forceLoadPreloadPlugin(id);
});
contextBridge.exposeInMainWorld('mainConfig', config);
contextBridge.exposeInMainWorld('electronIs', is);
contextBridge.exposeInMainWorld('ipcRenderer', {
on: (channel: string, listener: (event: IpcRendererEvent, ...args: unknown[]) => void) => ipcRenderer.on(channel, listener),
off: (channel: string, listener: (...args: unknown[]) => void) => ipcRenderer.off(channel, listener),
once: (channel: string, listener: (event: IpcRendererEvent, ...args: unknown[]) => void) => ipcRenderer.once(channel, listener),
send: (channel: string, ...args: unknown[]) => ipcRenderer.send(channel, ...args),
removeListener: (channel: string, listener: (...args: unknown[]) => void) => ipcRenderer.removeListener(channel, listener),
removeAllListeners: (channel: string) => ipcRenderer.removeAllListeners(channel),
invoke: async (channel: string, ...args: unknown[]): Promise<unknown> => ipcRenderer.invoke(channel, ...args),
sendSync: (channel: string, ...args: unknown[]): unknown => ipcRenderer.sendSync(channel, ...args),
sendToHost: (channel: string, ...args: unknown[]) => ipcRenderer.sendToHost(channel, ...args),
on: (
channel: string,
listener: (event: IpcRendererEvent, ...args: unknown[]) => void,
) => ipcRenderer.on(channel, listener),
off: (channel: string, listener: (...args: unknown[]) => void) =>
ipcRenderer.off(channel, listener),
once: (
channel: string,
listener: (event: IpcRendererEvent, ...args: unknown[]) => void,
) => ipcRenderer.once(channel, listener),
send: (channel: string, ...args: unknown[]) =>
ipcRenderer.send(channel, ...args),
removeListener: (channel: string, listener: (...args: unknown[]) => void) =>
ipcRenderer.removeListener(channel, listener),
removeAllListeners: (channel: string) =>
ipcRenderer.removeAllListeners(channel),
invoke: async (channel: string, ...args: unknown[]): Promise<unknown> =>
ipcRenderer.invoke(channel, ...args),
sendSync: (channel: string, ...args: unknown[]): unknown =>
ipcRenderer.sendSync(channel, ...args),
sendToHost: (channel: string, ...args: unknown[]) =>
ipcRenderer.sendToHost(channel, ...args),
});
contextBridge.exposeInMainWorld('reload', () => ipcRenderer.send('reload'));
contextBridge.exposeInMainWorld('ELECTRON_RENDERER_URL', process.env.ELECTRON_RENDERER_URL);
contextBridge.exposeInMainWorld(
'ELECTRON_RENDERER_URL',
process.env.ELECTRON_RENDERER_URL,
);

View File

@ -1,19 +1,22 @@
import { rendererPlugins } from 'virtual:RendererPlugins';
import { pluginBuilders } from 'virtual:PluginBuilders';
import { rendererPlugins } from 'virtual:plugins';
import { PluginBaseConfig, PluginBuilder, RendererPluginFactory } from './plugins/utils/builder';
import {
PluginBaseConfig,
PluginBuilder,
RendererPluginFactory,
} from '@/plugins/utils/builder';
import { startingPages } from './providers/extracted-data';
import setupSongInfo from './providers/song-info-front';
import {
forceLoadRendererPlugin,
forceUnloadRendererPlugin,
getAllLoadedRendererPlugins, getLoadedRendererPlugin,
getAllLoadedRendererPlugins,
getLoadedRendererPlugin,
loadAllRendererPlugins,
registerRendererPlugin
} from './loader/renderer';
import type { YoutubePlayer } from './types/youtube-player';
import type { YoutubePlayer } from '@/types/youtube-player';
let api: (Element & YoutubePlayer) | null = null;
@ -34,7 +37,10 @@ function listenForApiLoad() {
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
interface YouTubeMusicAppElement extends HTMLElement {
@ -75,17 +81,14 @@ function onApiLoaded() {
{ passive: true },
);
Object.values(getAllLoadedRendererPlugins())
.forEach((plugin) => {
plugin.onPlayerApiReady?.(api!);
});
window.ipcRenderer.send('ytmd:player-api-loaded');
// Navigate to "Starting page"
const startingPage: string = window.mainConfig.get('options.startingPage');
if (startingPage && startingPages[startingPage]) {
document.querySelector<YouTubeMusicAppElement>('ytmusic-app')?.navigate_(startingPages[startingPage]);
document
.querySelector<YouTubeMusicAppElement>('ytmusic-app')
?.navigate_(startingPages[startingPage]);
}
// Remove upgrade button
@ -98,51 +101,54 @@ function onApiLoaded() {
}
// Hide / Force show like buttons
const likeButtonsOptions: string = window.mainConfig.get('options.likeButtons');
const likeButtonsOptions: string = window.mainConfig.get(
'options.likeButtons',
);
if (likeButtonsOptions) {
const likeButtons: HTMLElement | null = document.querySelector('ytmusic-like-button-renderer');
const likeButtons: HTMLElement | null = document.querySelector(
'ytmusic-like-button-renderer',
);
if (likeButtons) {
likeButtons.style.display
= {
hide: 'none',
force: 'inherit',
}[likeButtonsOptions] || '';
likeButtons.style.display =
{
hide: 'none',
force: 'inherit',
}[likeButtonsOptions] || '';
}
}
}
(async () => {
Object.entries(pluginBuilders).forEach(([id, builder]) => {
const typedBuilder = builder as PluginBuilder<string, PluginBaseConfig>;
const plugin = rendererPlugins[id] as RendererPluginFactory<PluginBaseConfig> | undefined;
(() => {
loadAllRendererPlugins();
registerRendererPlugin(id, typedBuilder, plugin);
});
await loadAllRendererPlugins();
window.ipcRenderer.on(
'plugin:unload',
(_event, id: keyof PluginBuilderList) => {
forceUnloadRendererPlugin(id);
},
);
window.ipcRenderer.on(
'plugin:enable',
(_event, id: keyof PluginBuilderList) => {
forceLoadRendererPlugin(id);
if (api) {
const plugin = getLoadedRendererPlugin(id);
}
},
);
window.ipcRenderer.on('plugin:unload', (_event, id: keyof PluginBuilderList) => {
forceUnloadRendererPlugin(id);
});
window.ipcRenderer.on('plugin:enable', async (_event, id: keyof PluginBuilderList) => {
await forceLoadRendererPlugin(id);
if (api) {
const plugin = getLoadedRendererPlugin(id);
if (plugin) plugin.onPlayerApiReady?.(api);
}
});
window.ipcRenderer.on('config-changed', (_event, id: string, newConfig: PluginBaseConfig) => {
const plugin = getAllLoadedRendererPlugins()[id];
if (plugin) plugin.onConfigChange?.(newConfig);
});
window.ipcRenderer.on(
'config-changed',
(_event, id: string, newConfig: PluginBaseConfig) => {
const plugin = getAllLoadedRendererPlugins()[id];
},
);
// Wait for complete load of YouTube api
listenForApiLoad();
// Blocks the "Are You Still There?" popup by setting the last active time to Date.now every 15min
setInterval(() => window._lact = Date.now(), 900_000);
setInterval(() => (window._lact = Date.now()), 900_000);
// Setup back to front logger
if (window.electronIs.dev()) {

33
src/types/contexts.ts Normal file
View File

@ -0,0 +1,33 @@
import type { IpcMain, IpcRenderer, BrowserWindow } from 'electron';
import type { PluginConfig } from '@/types/plugins';
export interface BackendContext {
getConfig(): PluginConfig;
setConfig(conf: Omit<PluginConfig, 'enabled'>): void;
ipc: {
handle: (event: string, listener: CallableFunction) => void;
on: (event: string, listener: CallableFunction) => void;
};
win: BrowserWindow;
electron: typeof import('electron');
}
export interface MenuContext {
getConfig(): PluginConfig;
setConfig(conf: Omit<PluginConfig, 'enabled'>): void;
window: BrowserWindow;
refresh: () => Promise<void> | void;
}
export interface PreloadContext {
getConfig(): PluginConfig;
setConfig(conf: Omit<PluginConfig, 'enabled'>): void;
}
export interface RendererContext {
getConfig(): PluginConfig;
setConfig(conf: Omit<PluginConfig, 'enabled'>): void;
}

38
src/types/plugins.ts Normal file
View File

@ -0,0 +1,38 @@
import type {
BackendContext,
MenuContext,
PreloadContext,
RendererContext,
} from './contexts';
type Author = string;
export type PluginConfig = {
enabled: boolean;
} & Record<string, unknown>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type PluginExtra = Record<string, any>;
type PluginLifecycle<T> =
| ((ctx: T) => void | Promise<void>)
| ({
start?(ctx: T): void | Promise<void>;
stop?(ctx: T): void | Promise<void>;
} & PluginExtra);
export interface PluginDef {
name: string;
authors?: Author[];
description?: string;
config: PluginConfig;
menu?: (ctx: MenuContext) => Electron.MenuItemConstructorOptions[];
restartNeeded?: boolean;
backend?: PluginLifecycle<BackendContext>;
preload?: PluginLifecycle<PreloadContext>;
renderer?: PluginLifecycle<RendererContext> & {
stylesheet?: string;
};
}

64
src/utils/index.ts Normal file
View File

@ -0,0 +1,64 @@
import {
BackendContext,
PreloadContext,
RendererContext,
} from '@/types/contexts';
import { PluginDef, PluginConfig } from '@/types/plugins';
export const createPlugin = (
def: Omit<PluginDef, 'config'> & {
config?: Omit<PluginConfig, 'enabled'>;
},
): PluginDef => def as PluginDef;
type Options =
| { ctx: 'backend'; context: BackendContext }
| { ctx: 'preload'; context: PreloadContext }
| { ctx: 'renderer'; context: RendererContext };
export const startPlugin = (id: string, def: PluginDef, options: Options) => {
const lifecycle: (ctx: (typeof options)['context']) => void =
typeof def[options.ctx] === 'function'
? def[options.ctx]
: // @ts-expect-error TS is dum dum
def[options.ctx]?.start;
if (!lifecycle) return false;
try {
const start = performance.now();
lifecycle(options.context);
// prettier-ignore
console.log(`[YTM] Executed ${id}::${options.ctx} in ${performance.now() - start} ms`);
return true;
} catch (err) {
console.log(`[YTM] Failed to start ${id}::${options.ctx}: ${String(err)}`);
return false;
}
};
export const stopPlugin = (id: string, def: PluginDef, options: Options) => {
if (!def[options.ctx]) return false;
if (typeof def[options.ctx] === 'function') return false;
// @ts-expect-error TS is dum dum
const stop: (ctx: typeof options.context) => void = def[options.ctx]?.stop;
if (!stop) return false;
try {
const start = performance.now();
stop(options.context);
// prettier-ignore
console.log(`[YTM] Executed ${id}::${options.ctx} in ${performance.now() - start} ms`);
return true;
} catch (err) {
console.log(
`[YTM] Failed to execute ${id}::${options.ctx}: ${String(err)}`,
);
return false;
}
};

View File

@ -1,27 +1,13 @@
declare module 'virtual:MainPlugins' {
import type { MainPluginFactory, PluginBaseConfig } from './plugins/utils/builder';
declare module 'virtual:plugins' {
import type { PluginDef } from '@/types/plugins';
export const mainPlugins: Record<string, MainPluginFactory<PluginBaseConfig>>;
export const mainPlugins: Record<string, PluginDef>;
export const menuPlugins: Record<string, PluginDef>;
export const preloadPlugins: Record<string, PluginDef>;
export const rendererPlugins: Record<string, PluginDef>;
export const allPlugins: Record<
string,
Omit<PluginDef, 'backend' | 'preload' | 'renderer'>
>;
}
declare module 'virtual:MenuPlugins' {
import type { MenuPluginFactory, PluginBaseConfig } from './plugins/utils/builder';
export const menuPlugins: Record<string, MenuPluginFactory<PluginBaseConfig>>;
}
declare module 'virtual:PreloadPlugins' {
import type { PreloadPluginFactory, PluginBaseConfig } from './plugins/utils/builder';
export const preloadPlugins: Record<string, PreloadPluginFactory<PluginBaseConfig>>;
}
declare module 'virtual:RendererPlugins' {
import type { RendererPluginFactory, PluginBaseConfig } from './plugins/utils/builder';
export const rendererPlugins: Record<string, RendererPluginFactory<PluginBaseConfig>>;
}
declare module 'virtual:PluginBuilders' {
export const pluginBuilders: PluginBuilderList;
}

View File

@ -1,4 +1,5 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"compilerOptions": {
"target": "ESNext",
"lib": ["dom", "dom.iterable", "es2022"],
@ -8,15 +9,17 @@
"esModuleInterop": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"baseUrl": "./src",
"baseUrl": ".",
"outDir": "./dist",
"strict": true,
"noImplicitAny": true,
"strictFunctionTypes": true,
"skipLibCheck": true
"skipLibCheck": true,
"paths": {
"@/*": ["./src/*"],
"@assets": ["./assets/*"]
}
},
"exclude": ["./dist"],
"paths": {
"*": ["*.d.ts"]
}
"include": ["electron.vite.config.ts", "./src/**/*"]
}

View File

@ -0,0 +1,63 @@
import { basename, relative, resolve, extname } from 'node:path';
import { globSync } from 'glob';
import { Project } from 'ts-morph';
const snakeToCamel = (text: string) =>
text.replace(/-(\w)/g, (_, letter: string) => letter.toUpperCase());
export const pluginVirtualModuleGenerator = (
mode: 'main' | 'preload' | 'renderer' | 'menu',
) => {
const project = new Project({
tsConfigFilePath: resolve(__dirname, '..', 'tsconfig.json'),
skipAddingFilesFromTsConfig: true,
skipLoadingLibFiles: true,
skipFileDependencyResolution: true,
});
const srcPath = resolve(__dirname, '..', 'src');
const plugins = globSync([
'src/plugins/*/index.{js,ts}',
'src/plugins/*.{js,ts}',
'!src/plugins/utils/**/*',
'!src/plugins/utils/*',
]).map((path) => {
let name = basename(path);
if (name === 'index.ts' || name === 'index.js') {
name = basename(resolve(path, '..'));
}
name = name.replace(extname(name), '');
return { name, path };
});
const src = project.createSourceFile('vm:pluginIndexes', (writer) => {
// prettier-ignore
for (const { name, path } of plugins) {
const relativePath = relative(resolve(srcPath, '..'), path).replace(/\\/g, '/');
writer.writeLine(`import ${snakeToCamel(name)}Plugin from "./${relativePath}";`);
}
writer.blankLine();
// Context-specific exports
writer.writeLine(`export const ${mode}Plugins = {`);
for (const { name } of plugins) {
writer.writeLine(` "${name}": ${snakeToCamel(name)}Plugin,`);
}
writer.writeLine('};');
writer.blankLine();
// All plugins export
writer.writeLine('export const allPlugins = {');
for (const { name } of plugins) {
writer.writeLine(` "${name}": ${snakeToCamel(name)}Plugin,`);
}
writer.writeLine('};');
writer.blankLine();
});
return src.getText();
};

View File

@ -0,0 +1,105 @@
import { readFile } from 'node:fs/promises';
import { resolve, basename } from 'node:path';
import { createFilter } from 'vite';
import { Project, ts, ObjectLiteralExpression } from 'ts-morph';
import type { PluginOption } from 'vite';
export default function (mode: 'backend' | 'preload' | 'renderer' | 'none') {
const pluginFilter = createFilter([
'src/plugins/*/index.{js,ts}',
'src/plugins/*',
]);
return <PluginOption>{
name: 'ytm-plugin-loader',
async load(id) {
if (!pluginFilter(id)) return null;
const project = new Project({
tsConfigFilePath: resolve(__dirname, '..', 'tsconfig.json'),
skipAddingFilesFromTsConfig: true,
skipLoadingLibFiles: true,
skipFileDependencyResolution: true,
});
const src = project.createSourceFile(
'_pf' + basename(id),
await readFile(id, 'utf8'),
);
const exports = src.getExportedDeclarations();
let objExpr: ObjectLiteralExpression | undefined = undefined;
for (const [name, [expr]] of exports) {
if (name !== 'default') continue;
switch (expr.getKind()) {
case ts.SyntaxKind.ObjectLiteralExpression: {
objExpr = expr.asKindOrThrow(ts.SyntaxKind.ObjectLiteralExpression);
break;
}
case ts.SyntaxKind.CallExpression: {
const callExpr = expr.asKindOrThrow(ts.SyntaxKind.CallExpression);
if (callExpr.getArguments().length !== 1) continue;
const name = callExpr.getExpression().getText();
if (name !== 'createPlugin') continue;
const arg = callExpr.getArguments()[0];
if (arg.getKind() !== ts.SyntaxKind.ObjectLiteralExpression)
continue;
objExpr = arg.asKindOrThrow(ts.SyntaxKind.ObjectLiteralExpression);
break;
}
}
}
if (!objExpr) return null;
const properties = objExpr.getProperties();
const propertyNames = properties.map((prop) => {
switch (prop.getKind()) {
case ts.SyntaxKind.PropertyAssignment:
return prop
.asKindOrThrow(ts.SyntaxKind.PropertyAssignment)
.getName();
case ts.SyntaxKind.ShorthandPropertyAssignment:
return prop
.asKindOrThrow(ts.SyntaxKind.ShorthandPropertyAssignment)
.getName();
case ts.SyntaxKind.MethodDeclaration:
return prop
.asKindOrThrow(ts.SyntaxKind.MethodDeclaration)
.getName();
default:
throw new Error('Not implemented');
}
});
const contexts = ['backend', 'preload', 'renderer'];
for (const ctx of contexts) {
if (mode === 'none') {
const index = propertyNames.indexOf(ctx);
if (index === -1) continue;
objExpr.getProperty(propertyNames[index])?.remove();
continue;
}
if (ctx === mode) continue;
const index = propertyNames.indexOf(ctx);
if (index === -1) continue;
objExpr.getProperty(propertyNames[index])?.remove();
}
return {
code: src.getText(),
ast: src,
};
},
};
}

View File

@ -1,49 +0,0 @@
import { existsSync } from 'node:fs';
import { basename, relative, resolve } from 'node:path';
import { globSync } from 'glob';
type PluginType = 'index' | 'main' | 'preload' | 'renderer' | 'menu';
const snakeToCamel = (text: string) => text.replace(/-(\w)/g, (_, letter: string) => letter.toUpperCase());
const getName = (mode: PluginType, name: string) => {
if (mode === 'index') {
return snakeToCamel(name);
}
return `${snakeToCamel(name)}Plugin`;
};
const getListName = (mode: PluginType) => {
if (mode === 'index') return 'pluginBuilders';
return `${mode}Plugins`;
};
export const pluginVirtualModuleGenerator = (mode: PluginType) => {
const srcPath = resolve(__dirname, '..', 'src');
const plugins = globSync(`${srcPath}/plugins/*`)
.map((path) => ({ name: basename(path), path }))
.filter(({ name, path }) => {
if (name.startsWith('utils')) return false;
return existsSync(resolve(path, `${mode}.ts`)) || (mode !== 'index' && existsSync(resolve(path, `${mode}`, 'index.ts')));
});
console.log('converted plugin list');
console.log(plugins.map((it) => it.name));
let result = '';
for (const { name, path } of plugins) {
result += `import ${getName(mode, name)} from "./${relative(resolve(srcPath, '..'), path).replace(/\\/g, '/')}/${mode}";\n`;
}
result += `export const ${getListName(mode)} = {\n`;
for (const { name } of plugins) {
result += ` "${name}": ${getName(mode, name)},\n`;
}
result += '};';
return result;
};