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 = { module.exports = {
extends: [ extends: [
'plugin:import/typescript',
'eslint:recommended', 'eslint:recommended',
'plugin:import/recommended', 'plugin:import/recommended',
'plugin:import/typescript',
'plugin:@typescript-eslint/eslint-recommended', 'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking', 'plugin:@typescript-eslint/recommended-requiring-type-checking',
@ -13,30 +13,51 @@ module.exports = {
project: './tsconfig.json', project: './tsconfig.json',
tsconfigRootDir: __dirname, tsconfigRootDir: __dirname,
sourceType: 'module', sourceType: 'module',
ecmaVersion: 'latest' ecmaVersion: 'latest',
}, },
rules: { rules: {
'arrow-parens': ['error', 'always'], 'arrow-parens': ['error', 'always'],
'object-curly-spacing': ['error', 'always'], 'object-curly-spacing': ['error', 'always'],
'@typescript-eslint/no-floating-promises': 'off', '@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-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/first': 'error',
'import/newline-after-import': 'error', 'import/newline-after-import': 'error',
'import/no-default-export': 'off', 'import/no-default-export': 'off',
'import/no-duplicates': 'error', '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': [ 'import/order': [
'error', 'error',
{ {
'groups': ['builtin', 'external', ['internal', 'index', 'sibling'], 'parent', 'type'], groups: [
'builtin',
'external',
['internal', 'index', 'sibling'],
'parent',
'type',
],
'newlines-between': 'always-and-inside-groups', 'newlines-between': 'always-and-inside-groups',
'alphabetize': {order: 'ignore', caseInsensitive: false} alphabetize: { order: 'ignore', caseInsensitive: false },
} },
], ],
'import/prefer-default-export': 'off', 'import/prefer-default-export': 'off',
'camelcase': ['error', {properties: 'never'}], camelcase: ['error', { properties: 'never' }],
'class-methods-use-this': 'off', 'class-methods-use-this': 'off',
'lines-around-comment': [ 'lines-around-comment': [
'error', 'error',
@ -49,17 +70,21 @@ module.exports = {
], ],
'max-len': 'off', 'max-len': 'off',
'no-mixed-operators': 'error', 'no-mixed-operators': 'error',
'no-multi-spaces': ['error', {ignoreEOLComments: true}], 'no-multi-spaces': ['error', { ignoreEOLComments: true }],
'no-tabs': 'error', 'no-tabs': 'error',
'no-void': 'error', 'no-void': 'error',
'no-empty': 'off', 'no-empty': 'off',
'prefer-promise-reject-errors': 'off', 'prefer-promise-reject-errors': 'off',
'quotes': ['error', 'single', { quotes: [
avoidEscape: true, 'error',
allowTemplateLiterals: false, 'single',
}], {
avoidEscape: true,
allowTemplateLiterals: false,
},
],
'quote-props': ['error', 'consistent'], 'quote-props': ['error', 'consistent'],
'semi': ['error', 'always'], semi: ['error', 'always'],
}, },
env: { env: {
browser: true, browser: true,
@ -67,4 +92,15 @@ module.exports = {
es6: true, es6: true,
}, },
ignorePatterns: ['dist', 'node_modules'], 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/releases
!.yarn/sdks !.yarn/sdks
!.yarn/versions !.yarn/versions
.vite-inspect

View File

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

View File

@ -94,11 +94,12 @@
"test": "playwright test", "test": "playwright test",
"test:debug": "cross-env DEBUG=pw:*,-pw:test:protocol playwright test", "test:debug": "cross-env DEBUG=pw:*,-pw:test:protocol playwright test",
"build": "electron-vite build", "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": "electron-vite preview",
"start:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 yarpm-pnpm run start", "start:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 yarpm-pnpm run start",
"dev": "electron-vite dev --watch", "dev": "electron-vite dev --watch",
"dev:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 yarpm-pnpm run dev", "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": "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: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", "dist:mac": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --mac dmg:x64 -p never",
@ -136,6 +137,7 @@
"dependencies": { "dependencies": {
"@cliqz/adblocker-electron": "1.26.11", "@cliqz/adblocker-electron": "1.26.11",
"@cliqz/adblocker-electron-preload": "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/core-mt": "0.12.0",
"@ffmpeg.wasm/main": "0.12.0", "@ffmpeg.wasm/main": "0.12.0",
"@foobar404/wave": "2.0.4", "@foobar404/wave": "2.0.4",
@ -155,6 +157,7 @@
"electron-store": "8.1.0", "electron-store": "8.1.0",
"electron-unhandled": "4.0.1", "electron-unhandled": "4.0.1",
"electron-updater": "6.1.4", "electron-updater": "6.1.4",
"eslint-import-resolver-typescript": "^3.6.1",
"fast-average-color": "9.4.0", "fast-average-color": "9.4.0",
"fast-equals": "^5.0.1", "fast-equals": "^5.0.1",
"filenamify": "6.0.0", "filenamify": "6.0.0",
@ -164,7 +167,9 @@
"keyboardevents-areequal": "0.2.2", "keyboardevents-areequal": "0.2.2",
"node-html-parser": "6.1.11", "node-html-parser": "6.1.11",
"node-id3": "0.2.6", "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", "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", "vudio": "2.1.1",
"x11": "2.3.0", "x11": "2.3.0",
"youtubei.js": "7.0.0" "youtubei.js": "7.0.0"
@ -194,6 +199,7 @@
"typescript": "5.2.2", "typescript": "5.2.2",
"utf-8-validate": "6.0.3", "utf-8-validate": "6.0.3",
"vite": "4.5.0", "vite": "4.5.0",
"vite-plugin-inspect": "^0.7.42",
"vite-plugin-resolve": "2.5.1", "vite-plugin-resolve": "2.5.1",
"ws": "8.14.2", "ws": "8.14.2",
"yarpm": "1.2.0" "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 { deepmerge } from 'deepmerge-ts';
import defaultConfig from './defaults'; import defaultConfig from './defaults';
import plugins from './plugins';
import store from './store'; import store from './store';
import plugins from './plugins';
import { restart } from '../providers/app-controls'; import { restart } from '../providers/app-controls';
@ -15,7 +16,7 @@ const setPartial = (key: string, value: object) => {
store.set(key, newValue); store.set(key, newValue);
}; };
function setMenuOption(key: string, value: unknown) { function setMenuOption(key: string, value: unknown) {
set(key, value); set(key, value);
if (store.get('options.restartOnConfigChanges')) { if (store.get('options.restartOnConfigChanges')) {
restart(); restart();
@ -24,24 +25,55 @@ function setMenuOption(key: string, value: unknown) {
// MAGIC OF TYPESCRIPT // MAGIC OF TYPESCRIPT
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, type Prev = [
11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]] never,
type Join<K, P> = K extends string | number ? 0,
P extends string | number ? 1,
`${K}${'' extends P ? '' : '.'}${P}` 2,
: never : never; 3,
type Paths<T, D extends number = 10> = [D] extends [never] ? never : T extends object ? 4,
{ [K in keyof T]-?: K extends string | number ? 5,
`${K}` | Join<K, Paths<T[K], Prev[D]>> 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
}[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 SplitKey<K> = K extends `${infer A}.${infer B}` ? [A, B] : [K, string];
type PathValue<T, K extends string> = type PathValue<T, K extends string> = SplitKey<K> extends [
SplitKey<K> extends [infer A extends keyof T, infer B extends string] infer A extends keyof T,
? PathValue<T[A], B> infer B extends string,
: T; ]
const get = <Key extends Paths<typeof defaultConfig>>(key: Key) => store.get(key) as PathValue<typeof defaultConfig, typeof key>; ? 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 { export default {
defaultConfig, defaultConfig,

View File

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

View File

@ -2,8 +2,19 @@ import path from 'node:path';
import url from 'node:url'; import url from 'node:url';
import fs from 'node:fs'; import fs from 'node:fs';
import { BrowserWindow, app, screen, globalShortcut, session, shell, dialog, ipcMain } from 'electron'; import {
import enhanceWebRequest, { BetterSession } from '@jellybrick/electron-better-web-request'; 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 is from 'electron-is';
import unhandled from 'electron-unhandled'; import unhandled from 'electron-unhandled';
import { autoUpdater } from 'electron-updater'; import { autoUpdater } from 'electron-updater';
@ -12,30 +23,31 @@ import { parse } from 'node-html-parser';
import { deepmerge } from 'deepmerge-ts'; import { deepmerge } from 'deepmerge-ts';
import { deepEqual } from 'fast-equals'; import { deepEqual } from 'fast-equals';
import { mainPlugins } from 'virtual:MainPlugins'; import { mainPlugins } from 'virtual:plugins';
import { pluginBuilders } from 'virtual:PluginBuilders';
import config from './config'; import config from '@/config';
import { refreshMenu, setApplicationMenu } from './menu'; import { refreshMenu, setApplicationMenu } from '@/menu';
import { fileExists, injectCSS, injectCSSAsFile } from './plugins/utils/main'; import { fileExists, injectCSS, injectCSSAsFile } from '@/plugins/utils/main';
import { isTesting } from './utils/testing'; import { isTesting } from '@/utils/testing';
import { setUpTray } from './tray'; import { setUpTray } from '@/tray';
import { setupSongInfo } from './providers/song-info'; import { setupSongInfo } from '@/providers/song-info';
import { restart, setupAppControls } from './providers/app-controls'; import { restart, setupAppControls } from '@/providers/app-controls';
import { APP_PROTOCOL, handleProtocol, setupProtocolHandler } from './providers/protocol-handler'; import {
APP_PROTOCOL,
handleProtocol,
setupProtocolHandler,
} from '@/providers/protocol-handler';
import youtubeMusicCSS from '@/youtube-music.css?inline';
import youtubeMusicCSS from './youtube-music.css?inline';
import { import {
forceLoadMainPlugin, forceLoadMainPlugin,
forceUnloadMainPlugin, forceUnloadMainPlugin,
getAllLoadedMainPlugins, getAllLoadedMainPlugins,
loadAllMainPlugins, loadAllMainPlugins,
registerMainPlugin } from '@/loader/main';
} from './loader/main'; import { PluginBaseConfig } from '@/plugins/utils/builder';
import { MainPluginFactory, PluginBaseConfig, PluginBuilder } from './plugins/utils/builder';
// Catch errors and log them // Catch errors and log them
unhandled({ unhandled({
@ -57,7 +69,10 @@ if (!gotTheLock) {
// SharedArrayBuffer: Required for downloader (@ffmpeg/core-mt) // SharedArrayBuffer: Required for downloader (@ffmpeg/core-mt)
// OverlayScrollbar: Required for overlay scrollbars // 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 (config.get('options.disableHardwareAcceleration')) {
if (is.dev()) { if (is.dev()) {
console.log('Disabling hardware acceleration'); console.log('Disabling hardware acceleration');
@ -95,42 +110,58 @@ function onClosed() {
ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins)); ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins));
const initHook = (win: BrowserWindow) => { 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(
ipcMain.handle('set-config', (_, name: string, obj: object) => config.setPartial(`plugins.${name}`, obj)); '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) => { config.watch((newValue, oldValue) => {
const newPluginConfigList = (newValue?.plugins ?? {}) as Record<string, unknown>; const newPluginConfigList = (newValue?.plugins ?? {}) as Record<
const oldPluginConfigList = (oldValue?.plugins ?? {}) as Record<string, unknown>; string,
unknown
>;
const oldPluginConfigList = (oldValue?.plugins ?? {}) as Record<
string,
unknown
>;
Object.entries(newPluginConfigList).forEach(([id, newPluginConfig]) => { Object.entries(newPluginConfigList).forEach(([id, newPluginConfig]) => {
const isEqual = deepEqual(oldPluginConfigList[id], newPluginConfig); const isEqual = deepEqual(oldPluginConfigList[id], newPluginConfig);
if (!isEqual) { if (!isEqual) {
const oldConfig = oldPluginConfigList[id] as PluginBaseConfig; 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 !== oldConfig?.enabled) {
if (config.enabled) { if (config.enabled) {
win.webContents.send('plugin:enable', id); win.webContents.send('plugin:enable', id);
ipcMain.emit('plugin:enable', id); ipcMain.emit('plugin:enable', id);
forceLoadMainPlugin(id as keyof PluginBuilderList, win); forceLoadMainPlugin(id, win);
} else { } else {
win.webContents.send('plugin:unload', id); win.webContents.send('plugin:unload', id);
ipcMain.emit('plugin:unload', id); ipcMain.emit('plugin:unload', id);
forceUnloadMainPlugin(id as keyof PluginBuilderList, win); forceUnloadMainPlugin(id, win);
} }
if (pluginBuilders[id as keyof PluginBuilderList].restartNeeded) { if (mainPlugins[id].restartNeeded) {
showNeedToRestartDialog(id as keyof PluginBuilderList); showNeedToRestartDialog(id);
} }
} }
const mainPlugin = getAllLoadedMainPlugins()[id]; const mainPlugin = getAllLoadedMainPlugins()[id];
if (mainPlugin) { if (mainPlugin) {
if (config.enabled) { if (config.enabled) {
mainPlugin.onConfigChange?.(config);
} }
} }
@ -140,14 +171,15 @@ const initHook = (win: BrowserWindow) => {
}); });
}; };
const showNeedToRestartDialog = (id: keyof PluginBuilderList) => { const showNeedToRestartDialog = (id: string) => {
const builder = pluginBuilders[id]; const plugin = mainPlugins[id];
const dialogOptions: Electron.MessageBoxOptions = { const dialogOptions: Electron.MessageBoxOptions = {
type: 'info', type: 'info',
buttons: ['Restart Now', 'Later'], buttons: ['Restart Now', 'Later'],
title: 'Restart Required', title: 'Restart Required',
message: `"${builder.name ?? builder.id}" needs to restart`, message: `"${plugin.name ?? id}" needs to restart`,
detail: `"${builder.name ?? builder.id}" plugin requires a restart to take effect`, detail: `"${plugin.name ?? id}" plugin requires a restart to take effect`,
defaultId: 0, defaultId: 0,
cancelId: 1, cancelId: 1,
}; };
@ -186,7 +218,10 @@ function initTheme(win: BrowserWindow) {
injectCSSAsFile(win.webContents, cssFile); 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() ...(isTesting()
? undefined ? undefined
: { : {
// Sandbox is only enabled in tests for now // Sandbox is only enabled in tests for now
// See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts // See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts
sandbox: false, sandbox: false,
}), }),
}, },
frame: !is.macOS() && !useInlineMenu, frame: !is.macOS() && !useInlineMenu,
titleBarOverlay: defaultTitleBarOverlayOptions, titleBarOverlay: defaultTitleBarOverlayOptions,
titleBarStyle: useInlineMenu titleBarStyle: useInlineMenu
? 'hidden' ? 'hidden'
: (is.macOS() : is.macOS()
? 'hiddenInset' ? 'hiddenInset'
: 'default'), : 'default',
autoHideMenuBar: config.get('options.hideMenu'), autoHideMenuBar: config.get('options.hideMenu'),
}); });
initHook(win); initHook(win);
initTheme(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); await loadAllMainPlugins(win);
if (windowPosition) { if (windowPosition) {
const { x: windowX, y: windowY } = windowPosition; const { x: windowX, y: windowY } = windowPosition;
const winSize = win.getSize(); const winSize = win.getSize();
const displaySize const displaySize = screen.getDisplayNearestPoint(windowPosition).bounds;
= screen.getDisplayNearestPoint(windowPosition).bounds;
if ( if (
windowX + winSize[0] < displaySize.x - 8 windowX + winSize[0] < displaySize.x - 8 ||
|| windowX - winSize[0] > displaySize.x + displaySize.width windowX - winSize[0] > displaySize.x + displaySize.width ||
|| windowY < displaySize.y - 8 windowY < displaySize.y - 8 ||
|| windowY > displaySize.y + displaySize.height windowY > displaySize.y + displaySize.height
) { ) {
// Window is offscreen // Window is offscreen
if (is.dev()) { if (is.dev()) {
console.log( 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 { } else {
@ -316,7 +348,11 @@ async function createMainWindow() {
const savedTimeouts: Record<string, NodeJS.Timeout | undefined> = {}; 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]) { if (savedTimeouts[key]) {
clearTimeout(savedTimeouts[key]); clearTimeout(savedTimeouts[key]);
} }
@ -343,7 +379,10 @@ async function createMainWindow() {
if (useInlineMenu) { if (useInlineMenu) {
win.setTitleBarOverlay({ win.setTitleBarOverlay({
...defaultTitleBarOverlayOptions, ...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 { } else {
const rendererPath = path.join(__dirname, '..', 'renderer'); 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 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'); const scriptString = fs.readFileSync(scriptPath, 'utf-8');
await win.webContents.executeJavaScriptInIsolatedWorld(0, [{ await win.webContents.executeJavaScriptInIsolatedWorld(
code: scriptString + ';0', 0,
url: url.pathToFileURL(scriptPath).toString(), [
}], true); {
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 originalUserAgent = win.webContents.userAgent;
const userAgents = { const userAgents = {
mac: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 12.1; rv:95.0) Gecko/20100101 Firefox/95.0', 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', linux: 'Mozilla/5.0 (Linux x86_64; rv:95.0) Gecko/20100101 Firefox/95.0',
}; };
const updatedUserAgent const updatedUserAgent = is.macOS()
= is.macOS() ? userAgents.mac ? userAgents.mac
: (is.windows() ? userAgents.windows : is.windows()
: userAgents.linux); ? userAgents.windows
: userAgents.linux;
win.webContents.userAgent = updatedUserAgent; win.webContents.userAgent = updatedUserAgent;
app.userAgentFallback = updatedUserAgent; app.userAgentFallback = updatedUserAgent;
win.webContents.session.webRequest.onBeforeSendHeaders((details, cb) => { win.webContents.session.webRequest.onBeforeSendHeaders((details, cb) => {
// This will only happen if login failed, and "retry" was pressed // 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; details.requestHeaders['User-Agent'] = originalUserAgent;
} }
@ -412,33 +467,41 @@ app.once('browser-window-created', (event, win) => {
setupSongInfo(win); setupSongInfo(win);
setupAppControls(); setupAppControls();
win.webContents.on('did-fail-load', ( win.webContents.on(
_event, 'did-fail-load',
errorCode, (
errorDescription, _event,
validatedURL,
isMainFrame,
frameProcessId,
frameRoutingId,
) => {
const log = JSON.stringify({
error: 'did-fail-load',
errorCode, errorCode,
errorDescription, errorDescription,
validatedURL, validatedURL,
isMainFrame, isMainFrame,
frameProcessId, frameProcessId,
frameRoutingId, frameRoutingId,
}, null, '\t'); ) => {
if (is.dev()) { const log = JSON.stringify(
console.log(log); {
} 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 if (errorCode !== -3) {
win.webContents.send('log', log); // -3 is a false positive
win.webContents.loadFile(path.join(__dirname, 'error.html')); win.webContents.send('log', log);
} win.webContents.loadFile(path.join(__dirname, 'error.html'));
}); }
},
);
win.webContents.on('will-prevent-unload', (event) => { win.webContents.on('will-prevent-unload', (event) => {
event.preventDefault(); event.preventDefault();
@ -484,17 +547,29 @@ app.on('ready', async () => {
const appLocation = process.execPath; const appLocation = process.execPath;
const appData = app.getPath('appData'); const appData = app.getPath('appData');
// Check shortcut validity if not in dev mode / running portable app // Check shortcut validity if not in dev mode / running portable app
if (!is.dev() && !appLocation.startsWith(path.join(appData, '..', 'Local', 'Temp'))) { if (
const shortcutPath = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'YouTube Music.lnk'); !is.dev() &&
try { // Check if shortcut is registered and valid !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 const shortcutDetails = shell.readShortcutLink(shortcutPath); // Throw error if doesn't exist yet
if ( if (
shortcutDetails.target !== appLocation shortcutDetails.target !== appLocation ||
|| shortcutDetails.appUserModelId !== appID shortcutDetails.appUserModelId !== appID
) { ) {
throw 'needUpdate'; throw 'needUpdate';
} }
} catch (error) { // If not valid -> Register shortcut } catch (error) {
// If not valid -> Register shortcut
shell.writeShortcutLink( shell.writeShortcutLink(
shortcutPath, shortcutPath,
error === 'needUpdate' ? 'update' : 'create', error === 'needUpdate' ? 'update' : 'create',
@ -556,8 +631,8 @@ app.on('ready', async () => {
clearTimeout(updateTimeout); clearTimeout(updateTimeout);
}, 2000); }, 2000);
autoUpdater.on('update-available', () => { autoUpdater.on('update-available', () => {
const downloadLink const downloadLink =
= 'https://github.com/th-ch/youtube-music/releases/latest'; 'https://github.com/th-ch/youtube-music/releases/latest';
const dialogOptions: Electron.MessageBoxOptions = { const dialogOptions: Electron.MessageBoxOptions = {
type: 'info', type: 'info',
buttons: ['OK', 'Download', 'Disable updates'], buttons: ['OK', 'Download', 'Disable updates'],
@ -597,8 +672,10 @@ app.on('ready', async () => {
if (config.get('options.hideMenu') && !config.get('options.hideMenuWarned')) { if (config.get('options.hideMenu') && !config.get('options.hideMenuWarned')) {
dialog.showMessageBox(mainWindow, { dialog.showMessageBox(mainWindow, {
type: 'info', title: 'Hide Menu Enabled', type: 'info',
message: "Menu is hidden, use 'Alt' to show it (or 'Escape' if using in-app-menu)", 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); 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) { if (details) {
console.log('Unresponsive Error!\n' + JSON.stringify(details, null, '\t')); console.log('Unresponsive Error!\n' + JSON.stringify(details, null, '\t'));
} }
dialog.showMessageBox(win, { dialog
type: 'error', .showMessageBox(win, {
title: 'Window Unresponsive', type: 'error',
message: 'The Application is Unresponsive', title: 'Window Unresponsive',
detail: 'We are sorry for the inconvenience! please choose what to do:', message: 'The Application is Unresponsive',
buttons: ['Wait', 'Relaunch', 'Quit'], detail: 'We are sorry for the inconvenience! please choose what to do:',
cancelId: 0, buttons: ['Wait', 'Relaunch', 'Quit'],
}).then((result) => { cancelId: 0,
switch (result.response) { })
case 1: { .then((result) => {
restart(); switch (result.response) {
break; case 1: {
} restart();
break;
}
case 2: { case 2: {
app.quit(); app.quit();
break; break;
}
} }
} });
});
} }
function removeContentSecurityPolicy( function removeContentSecurityPolicy(
@ -671,18 +753,21 @@ function removeContentSecurityPolicy(
}); });
// When multiple listeners are defined, apply them all // When multiple listeners are defined, apply them all
betterSession.webRequest.setResolver('onHeadersReceived', async (listeners) => { betterSession.webRequest.setResolver(
return listeners.reduce( 'onHeadersReceived',
async (accumulator, listener) => { async (listeners) => {
const acc = await accumulator; return listeners.reduce(
if (acc.cancel) { async (accumulator, listener) => {
return acc; const acc = await accumulator;
} if (acc.cancel) {
return acc;
}
const result = await listener.apply(); const result = await listener.apply();
return { ...accumulator, ...result }; return { ...accumulator, ...result };
}, },
Promise.resolve({ cancel: false }), 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 { BrowserWindow, ipcMain } from 'electron';
import { deepmerge } from 'deepmerge-ts'; import { deepmerge } from 'deepmerge-ts';
import { mainPlugins } from 'virtual:plugins';
import config from '../config'; import { PluginDef } from '@/types/plugins';
import { injectCSS } from '../plugins/utils/main'; import { BackendContext } from '@/types/contexts';
import { import config from '@/config';
MainPlugin, import { startPlugin, stopPlugin } from '@/utils';
MainPluginContext,
MainPluginFactory,
PluginBaseConfig,
PluginBuilder
} from '../plugins/utils/builder';
const allPluginFactoryList: Record<string, MainPluginFactory<PluginBaseConfig>> = {}; const loadedPluginMap: Record<string, PluginDef> = {};
const allPluginBuilders: Record<string, PluginBuilder<string, PluginBaseConfig>> = {};
const unregisterStyleMap: Record<string, (() => void)[]> = {};
const loadedPluginMap: Record<string, MainPlugin<PluginBaseConfig>> = {};
const createContext = < const createContext = (id: string, win: BrowserWindow): BackendContext => ({
Key extends keyof PluginBuilderList, getConfig: () =>
Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], // @ts-expect-error ts dum dum
>(id: Key, win: BrowserWindow): MainPluginContext<Config> => ({ deepmerge(
getConfig: () => deepmerge(allPluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as Config, mainPlugins[id].config,
config.get(`plugins.${id}`) ?? { enabled: false },
),
setConfig: (newConfig) => { setConfig: (newConfig) => {
config.setPartial(`plugins.${id}`, newConfig); config.setPartial(`plugins.${id}`, newConfig);
}, },
@ -29,81 +26,95 @@ const createContext = <
send: (event: string, ...args: unknown[]) => { send: (event: string, ...args: unknown[]) => {
win.webContents.send(event, ...args); win.webContents.send(event, ...args);
}, },
// @ts-expect-error ts dum dum
handle: (event: string, listener) => { 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) => { 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) => { export const forceUnloadMainPlugin = async (
unregisterStyleMap[id]?.forEach((unregister) => unregister()); id: string,
delete unregisterStyleMap[id]; win: BrowserWindow,
): Promise<void> => {
const plugin = loadedPluginMap[id]!;
if (!plugin) return;
loadedPluginMap[id]?.onUnload?.(win); return new Promise<void>((resolve, reject) => {
delete loadedPluginMap[id]; 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) => { export const forceLoadMainPlugin = async (
const builder = allPluginBuilders[id]; id: string,
win: BrowserWindow,
): Promise<void> => {
const plugin = mainPlugins[id];
if (!plugin.backend) return;
Promise.allSettled( return new Promise<void>((resolve, reject) => {
builder.styles?.map(async (style) => { try {
const unregister = await injectCSS(win.webContents, style); const hasStarted = startPlugin(id, plugin, {
console.log('[YTMusic]', `Injected CSS for "${id}" plugin`); ctx: 'backend',
context: createContext(id, win),
return unregister; });
}) ?? [], if (!hasStarted) {
).then((result) => { console.log('[YTMusic]', `Cannot load "${id}" plugin`);
unregisterStyleMap[id] = result reject();
.map((it) => it.status === 'fulfilled' && it.value) return;
.filter(Boolean);
let isInjectSuccess = true;
result.forEach((it) => {
if (it.status === 'rejected') {
isInjectSuccess = false;
console.log('[YTMusic]', `Cannot inject "${id}" plugin style: ${String(it.reason)}`);
} }
});
if (isInjectSuccess) console.log('[YTMusic]', `"${id}" plugin data is loaded`); 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) => { export const loadAllMainPlugins = async (win: BrowserWindow) => {
const pluginConfigs = config.plugins.getPlugins(); const pluginConfigs = config.plugins.getPlugins();
const queue: Promise<void>[] = [];
for (const [pluginId, builder] of Object.entries(allPluginBuilders)) { for (const [plugin, pluginDef] of Object.entries(mainPlugins)) {
const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList]; const config = deepmerge(pluginDef.config, pluginConfigs[plugin] ?? {});
const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {});
if (config.enabled) { if (config.enabled) {
await forceLoadMainPlugin(pluginId as keyof PluginBuilderList, win); queue.push(forceLoadMainPlugin(plugin, win));
} else { } else if (loadedPluginMap[plugin]) {
if (loadedPluginMap[pluginId as keyof PluginBuilderList]) { queue.push(forceUnloadMainPlugin(plugin, win));
forceUnloadMainPlugin(pluginId as keyof PluginBuilderList, win);
}
} }
} }
await Promise.all(queue);
}; };
export const unloadAllMainPlugins = (win: BrowserWindow) => { 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]; return loadedPluginMap[id];
}; };
export const getAllLoadedMainPlugins = () => { export const getAllLoadedMainPlugins = () => {
return loadedPluginMap; 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 { deepmerge } from 'deepmerge-ts';
import { allPlugins } from 'virtual:plugins';
import { BrowserWindow, MenuItemConstructorOptions } from 'electron';
import { MenuPluginContext, MenuPluginFactory, PluginBaseConfig, PluginBuilder } from '../plugins/utils/builder'; import { MenuContext } from '@/types/contexts';
import config from '../config';
import { setApplicationMenu } from '../menu';
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 menuTemplateMap: Record<string, MenuItemConstructorOptions[]> = {};
const createContext = (id: string, win: BrowserWindow): MenuContext => ({
const createContext = < getConfig: () => config.plugins.getOptions(id),
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,
setConfig: (newConfig) => { setConfig: (newConfig) => {
config.setPartial(`plugins.${id}`, newConfig); config.setPartial(`plugins.${id}`, newConfig);
}, },
window: win, window: win,
refresh: async () => { refresh: () => {
await setApplicationMenu(win); setApplicationMenu(win);
if (config.plugins.isEnabled('in-app-menu')) { if (config.plugins.isEnabled('in-app-menu')) {
win.webContents.send('refresh-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 { try {
const factory = allPluginFactoryList[id]; const plugin = allPlugins[id];
if (!factory) return; if (!plugin) return;
const context = createContext(id, win); const menu = plugin.menu?.(createContext(id, win));
menuTemplateMap[id] = await factory(context); if (menu) menuTemplateMap[id] = menu;
else return;
console.log('[YTMusic]', `"${id}" plugin is loaded`); console.log('[YTMusic]', `Successfully loaded '${id}::menu'`);
} catch (err) { } 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(); const pluginConfigs = config.plugins.getPlugins();
for (const [pluginId, builder] of Object.entries(allPluginBuilders)) { for (const [pluginId, pluginDef] of Object.entries(allPlugins)) {
const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList]; const config = deepmerge(pluginDef.config, pluginConfigs[pluginId] ?? {});
const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {});
if (config.enabled) { 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]; return menuTemplateMap[id];
}; };
export const getAllMenuTemplate = () => { export const getAllMenuTemplate = () => {
return menuTemplateMap; 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 { deepmerge } from 'deepmerge-ts';
import { preloadPlugins } from 'virtual:plugins';
import { import { type PluginDef } from '@/types/plugins';
PluginBaseConfig, import { type PreloadContext } from '@/types/contexts';
PluginBuilder, import { startPlugin, stopPlugin } from '@/utils';
PreloadPlugin,
PluginContext,
PreloadPluginFactory
} from '../plugins/utils/builder';
import config from '../config';
const allPluginFactoryList: Record<string, PreloadPluginFactory<PluginBaseConfig>> = {}; import config from '@/config';
const allPluginBuilders: Record<string, PluginBuilder<string, PluginBaseConfig>> = {};
const unregisterStyleMap: Record<string, (() => void)[]> = {};
const loadedPluginMap: Record<string, PreloadPlugin<PluginBaseConfig>> = {};
const createContext = < const loadedPluginMap: Record<string, PluginDef> = {};
Key extends keyof PluginBuilderList, const createContext = (id: string): PreloadContext => ({
Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], getConfig: () => config.plugins.getOptions(id),
>(id: Key): PluginContext<Config> => ({
getConfig: () => deepmerge(allPluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as Config,
setConfig: (newConfig) => { setConfig: (newConfig) => {
config.setPartial(`plugins.${id}`, newConfig); config.setPartial(`plugins.${id}`, newConfig);
}, },
}); });
export const forceUnloadPreloadPlugin = (id: keyof PluginBuilderList) => { export const forceUnloadPreloadPlugin = (id: string) => {
unregisterStyleMap[id]?.forEach((unregister) => unregister()); const hasStopped = stopPlugin(id, loadedPluginMap[id], {
delete unregisterStyleMap[id]; ctx: 'preload',
context: createContext(id),
loadedPluginMap[id]?.onUnload?.(); });
delete loadedPluginMap[id]; if (!hasStopped) {
console.log('[YTMusic]', `Cannot stop "${id}" plugin`);
return;
}
console.log('[YTMusic]', `"${id}" plugin is unloaded`); console.log('[YTMusic]', `"${id}" plugin is unloaded`);
}; };
export const forceLoadPreloadPlugin = async (id: keyof PluginBuilderList) => { export const forceLoadPreloadPlugin = (id: string) => {
try { try {
const factory = allPluginFactoryList[id]; const plugin = preloadPlugins[id];
if (!factory) return; if (!plugin) return;
const context = createContext(id); const hasStarted = startPlugin(id, plugin, {
const plugin = await factory(context); ctx: 'preload',
loadedPluginMap[id] = plugin; context: createContext(id),
plugin.onLoad?.(); });
if (hasStarted) loadedPluginMap[id] = plugin;
console.log('[YTMusic]', `"${id}" plugin is loaded`); console.log('[YTMusic]', `"${id}" plugin is loaded`);
} catch (err) { } 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(); const pluginConfigs = config.plugins.getPlugins();
for (const [pluginId, builder] of Object.entries(allPluginBuilders)) { for (const [pluginId, pluginDef] of Object.entries(preloadPlugins)) {
const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList]; const config = deepmerge(pluginDef.config, pluginConfigs[pluginId] ?? {});
const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {});
if (config.enabled) { if (config.enabled) {
await forceLoadPreloadPlugin(pluginId as keyof PluginBuilderList); forceLoadPreloadPlugin(pluginId);
} else { } else {
if (loadedPluginMap[pluginId as keyof PluginBuilderList]) { if (loadedPluginMap[pluginId]) {
forceUnloadPreloadPlugin(pluginId as keyof PluginBuilderList); 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]; return loadedPluginMap[id];
}; };
export const getAllLoadedPreloadPlugins = () => { export const getAllLoadedPreloadPlugins = () => {
return loadedPluginMap; 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 { deepmerge } from 'deepmerge-ts';
import { import { rendererPlugins } from 'virtual:plugins';
PluginBaseConfig, PluginBuilder,
RendererPlugin, import { RendererContext } from '@/types/contexts';
RendererPluginContext,
RendererPluginFactory import { PluginDef } from '@/types/plugins';
} from '../plugins/utils/builder'; import { startPlugin, stopPlugin } from '@/utils';
const allPluginFactoryList: Record<string, RendererPluginFactory<PluginBaseConfig>> = {};
const allPluginBuilders: Record<string, PluginBuilder<string, PluginBaseConfig>> = {};
const unregisterStyleMap: Record<string, (() => void)[]> = {}; const unregisterStyleMap: Record<string, (() => void)[]> = {};
const loadedPluginMap: Record<string, RendererPlugin<PluginBaseConfig>> = {}; const loadedPluginMap: Record<string, PluginDef> = {};
const createContext = < const createContext = (id: string): RendererContext => ({
Key extends keyof PluginBuilderList, getConfig: () => window.mainConfig.plugins.getOptions(id),
Config extends PluginBaseConfig = PluginBuilderList[Key]['config'],
>(id: Key): RendererPluginContext<Config> => ({
getConfig: async () => {
return await window.ipcRenderer.invoke('get-config', id) as Config;
},
setConfig: async (newConfig) => { setConfig: async (newConfig) => {
await window.ipcRenderer.invoke('set-config', id, 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()); unregisterStyleMap[id]?.forEach((unregister) => unregister());
delete unregisterStyleMap[id];
loadedPluginMap[id]?.onUnload?.(); delete unregisterStyleMap[id];
delete loadedPluginMap[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`); console.log('[YTMusic]', `"${id}" plugin is unloaded`);
}; };
export const forceLoadRendererPlugin = async (id: keyof PluginBuilderList) => { export const forceLoadRendererPlugin = (id: string) => {
try { const plugin = rendererPlugins[id];
const factory = allPluginFactoryList[id]; if (!plugin) return;
if (!factory) return;
const context = createContext(id); const hasEvaled = startPlugin(id, plugin, {
const plugin = await factory(context); ctx: 'renderer',
context: createContext(id),
});
if (hasEvaled || plugin.renderer?.stylesheet) {
loadedPluginMap[id] = plugin; loadedPluginMap[id] = plugin;
plugin.onLoad?.();
console.log('[YTMusic]', `"${id}" plugin is loaded`); if (plugin.renderer?.stylesheet)
} catch (err) { document.head.appendChild(
console.log('[YTMusic]', `Cannot initialize "${id}" plugin: ${String(err)}`); 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(); const pluginConfigs = window.mainConfig.plugins.getPlugins();
for (const [pluginId, builder] of Object.entries(allPluginBuilders)) { for (const [pluginId, pluginDef] of Object.entries(rendererPlugins)) {
const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList]; const config = deepmerge(pluginDef.config, pluginConfigs[pluginId] ?? {});
const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {});
if (config.enabled) { if (config.enabled) {
await forceLoadRendererPlugin(pluginId as keyof PluginBuilderList); forceLoadRendererPlugin(pluginId);
} else { } else {
if (loadedPluginMap[pluginId as keyof PluginBuilderList]) { if (loadedPluginMap[pluginId]) {
forceUnloadRendererPlugin(pluginId as keyof PluginBuilderList); forceUnloadRendererPlugin(pluginId);
} }
} }
} }
@ -77,21 +77,14 @@ export const loadAllRendererPlugins = async () => {
export const unloadAllRendererPlugins = () => { export const unloadAllRendererPlugins = () => {
for (const id of Object.keys(loadedPluginMap)) { 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]; return loadedPluginMap[id];
}; };
export const getAllLoadedRendererPlugins = () => { export const getAllLoadedRendererPlugins = () => {
return loadedPluginMap; 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 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 prompt from 'custom-electron-prompt';
import { restart } from './providers/app-controls'; import { restart } from './providers/app-controls';
@ -8,24 +15,22 @@ import { startingPages } from './providers/extracted-data';
import promptOptions from './providers/prompt-options'; import promptOptions from './providers/prompt-options';
/* eslint-disable import/order */ /* eslint-disable import/order */
import { menuPlugins as menuList } from 'virtual:MenuPlugins'; import { allPlugins } from 'virtual:plugins';
import { pluginBuilders } from 'virtual:PluginBuilders';
/* eslint-enable import/order */ /* eslint-enable import/order */
import { getAvailablePluginNames } from './plugins/utils/main'; import { getAllMenuTemplate, loadAllMenuPlugins } from './loader/menu';
import {
MenuPluginFactory,
PluginBaseConfig,
PluginBuilder
} from './plugins/utils/builder';
import { getAllMenuTemplate, loadAllMenuPlugins, registerMenuPlugin } from './loader/menu';
export type MenuTemplate = Electron.MenuItemConstructorOptions[]; export type MenuTemplate = Electron.MenuItemConstructorOptions[];
// True only if in-app-menu was loaded on launch // True only if in-app-menu was loaded on launch
const inAppMenuActive = config.plugins.isEnabled('in-app-menu'); 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, label: label || plugin,
type: 'checkbox', type: 'checkbox',
checked: config.plugins.isEnabled(plugin), checked: config.plugins.isEnabled(plugin),
@ -49,47 +54,42 @@ export const refreshMenu = (win: BrowserWindow) => {
} }
}; };
Object.entries(pluginBuilders).forEach(([id, builder]) => { export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
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> => {
const innerRefreshMenu = () => refreshMenu(win); const innerRefreshMenu = () => refreshMenu(win);
await loadAllMenuPlugins(win); loadAllMenuPlugins(win);
const menuResult = Object.entries(getAllMenuTemplate()).map(([id, template]) => { const menuResult = Object.entries(getAllMenuTemplate()).map(
const pluginLabel = (pluginBuilders[id as keyof PluginBuilderList])?.name ?? id; ([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 [ return [
id, id,
pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu), {
label: pluginLabel,
submenu: [
pluginEnabledMenu(id, 'Enabled', true, innerRefreshMenu),
{ type: 'separator' },
...template,
],
} satisfies Electron.MenuItemConstructorOptions,
] as const; ] as const;
} },
);
return [ const availablePlugins = Object.keys(allPlugins);
id,
{
label: pluginLabel,
submenu: [
pluginEnabledMenu(id, 'Enabled', true, innerRefreshMenu),
{ type: 'separator' },
...template,
],
} satisfies Electron.MenuItemConstructorOptions
] as const;
});
const availablePlugins = getAvailablePluginNames();
const pluginMenus = availablePlugins.map((id) => { const pluginMenus = availablePlugins.map((id) => {
const predefinedTemplate = menuResult.find((it) => it[0] === id); const predefinedTemplate = menuResult.find((it) => it[0] === id);
if (predefinedTemplate) return predefinedTemplate[1]; 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); return pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu);
}); });
@ -106,7 +106,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
label: 'Auto-update', label: 'Auto-update',
type: 'checkbox', type: 'checkbox',
checked: config.get('options.autoUpdates'), checked: config.get('options.autoUpdates'),
click(item) { click(item: MenuItem) {
config.setMenuOption('options.autoUpdates', item.checked); 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', label: 'Resume last song when app starts',
type: 'checkbox', type: 'checkbox',
checked: config.get('options.resumeOnStart'), checked: config.get('options.resumeOnStart'),
click(item) { click(item: MenuItem) {
config.setMenuOption('options.resumeOnStart', item.checked); config.setMenuOption('options.resumeOnStart', item.checked);
}, },
}, },
{ {
label: 'Starting page', label: 'Starting page',
submenu: (() => { submenu: (() => {
const subMenuArray: Electron.MenuItemConstructorOptions[] = Object.keys(startingPages).map((name) => ({ const subMenuArray: Electron.MenuItemConstructorOptions[] =
label: name, Object.keys(startingPages).map((name) => ({
type: 'radio', label: name,
checked: config.get('options.startingPage') === name, type: 'radio',
click() { checked: config.get('options.startingPage') === name,
config.set('options.startingPage', name); click() {
}, config.set('options.startingPage', name);
})); },
}));
subMenuArray.unshift({ subMenuArray.unshift({
label: 'Unset', label: 'Unset',
type: 'radio', type: 'radio',
@ -147,8 +148,11 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
label: 'Remove upgrade button', label: 'Remove upgrade button',
type: 'checkbox', type: 'checkbox',
checked: config.get('options.removeUpgradeButton'), checked: config.get('options.removeUpgradeButton'),
click(item) { click(item: MenuItem) {
config.setMenuOption('options.removeUpgradeButton', item.checked); config.setMenuOption(
'options.removeUpgradeButton',
item.checked,
);
}, },
}, },
{ {
@ -213,7 +217,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
label: 'Single instance lock', label: 'Single instance lock',
type: 'checkbox', type: 'checkbox',
checked: true, checked: true,
click(item) { click(item: MenuItem) {
if (!item.checked && app.hasSingleInstanceLock()) { if (!item.checked && app.hasSingleInstanceLock()) {
app.releaseSingleInstanceLock(); app.releaseSingleInstanceLock();
} else if (item.checked && !app.hasSingleInstanceLock()) { } else if (item.checked && !app.hasSingleInstanceLock()) {
@ -225,43 +229,45 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
label: 'Always on top', label: 'Always on top',
type: 'checkbox', type: 'checkbox',
checked: config.get('options.alwaysOnTop'), checked: config.get('options.alwaysOnTop'),
click(item) { click(item: MenuItem) {
config.setMenuOption('options.alwaysOnTop', item.checked); config.setMenuOption('options.alwaysOnTop', item.checked);
win.setAlwaysOnTop(item.checked); win.setAlwaysOnTop(item.checked);
}, },
}, },
...(is.windows() || is.linux() ...((is.windows() || is.linux()
? [ ? [
{ {
label: 'Hide menu', label: 'Hide menu',
type: 'checkbox', type: 'checkbox',
checked: config.get('options.hideMenu'), checked: config.get('options.hideMenu'),
click(item) { click(item) {
config.setMenuOption('options.hideMenu', item.checked); config.setMenuOption('options.hideMenu', item.checked);
if (item.checked && !config.get('options.hideMenuWarned')) { if (item.checked && !config.get('options.hideMenuWarned')) {
dialog.showMessageBox(win, { dialog.showMessageBox(win, {
type: 'info', title: 'Hide Menu Enabled', type: 'info',
message: 'Menu will be hidden on next launch, use [Alt] to show it (or backtick [`] if using in-app-menu)', 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[]),
: []) satisfies Electron.MenuItemConstructorOptions[], ...((is.windows() || is.macOS()
...(is.windows() || is.macOS()
? // Only works on Win/Mac ? // Only works on Win/Mac
// https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows // https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows
[ [
{ {
label: 'Start at login', label: 'Start at login',
type: 'checkbox', type: 'checkbox',
checked: config.get('options.startAtLogin'), checked: config.get('options.startAtLogin'),
click(item) { click(item) {
config.setMenuOption('options.startAtLogin', item.checked); config.setMenuOption('options.startAtLogin', item.checked);
},
}, },
}, ]
] : []) satisfies Electron.MenuItemConstructorOptions[]),
: []) satisfies Electron.MenuItemConstructorOptions[],
{ {
label: 'Tray', label: 'Tray',
submenu: [ submenu: [
@ -277,7 +283,8 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
{ {
label: 'Enabled + app visible', label: 'Enabled + app visible',
type: 'radio', type: 'radio',
checked: config.get('options.tray') && config.get('options.appVisible'), checked:
config.get('options.tray') && config.get('options.appVisible'),
click() { click() {
config.setMenuOption('options.tray', true); config.setMenuOption('options.tray', true);
config.setMenuOption('options.appVisible', true); config.setMenuOption('options.appVisible', true);
@ -286,7 +293,8 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
{ {
label: 'Enabled + app hidden', label: 'Enabled + app hidden',
type: 'radio', type: 'radio',
checked: config.get('options.tray') && !config.get('options.appVisible'), checked:
config.get('options.tray') && !config.get('options.appVisible'),
click() { click() {
config.setMenuOption('options.tray', true); config.setMenuOption('options.tray', true);
config.setMenuOption('options.appVisible', false); config.setMenuOption('options.appVisible', false);
@ -297,8 +305,11 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
label: 'Play/Pause on click', label: 'Play/Pause on click',
type: 'checkbox', type: 'checkbox',
checked: config.get('options.trayClickPlayPause'), checked: config.get('options.trayClickPlayPause'),
click(item) { click(item: MenuItem) {
config.setMenuOption('options.trayClickPlayPause', item.checked); config.setMenuOption(
'options.trayClickPlayPause',
item.checked,
);
}, },
}, },
], ],
@ -310,7 +321,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
{ {
label: 'Set Proxy', label: 'Set Proxy',
type: 'normal', type: 'normal',
async click(item) { async click(item: MenuItem) {
await setProxy(item, win); await setProxy(item, win);
}, },
}, },
@ -318,7 +329,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
label: 'Override useragent', label: 'Override useragent',
type: 'checkbox', type: 'checkbox',
checked: config.get('options.overrideUserAgent'), checked: config.get('options.overrideUserAgent'),
click(item) { click(item: MenuItem) {
config.setMenuOption('options.overrideUserAgent', item.checked); config.setMenuOption('options.overrideUserAgent', item.checked);
}, },
}, },
@ -326,40 +337,46 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
label: 'Disable hardware acceleration', label: 'Disable hardware acceleration',
type: 'checkbox', type: 'checkbox',
checked: config.get('options.disableHardwareAcceleration'), checked: config.get('options.disableHardwareAcceleration'),
click(item) { click(item: MenuItem) {
config.setMenuOption('options.disableHardwareAcceleration', item.checked); config.setMenuOption(
'options.disableHardwareAcceleration',
item.checked,
);
}, },
}, },
{ {
label: 'Restart on config changes', label: 'Restart on config changes',
type: 'checkbox', type: 'checkbox',
checked: config.get('options.restartOnConfigChanges'), checked: config.get('options.restartOnConfigChanges'),
click(item) { click(item: MenuItem) {
config.setMenuOption('options.restartOnConfigChanges', item.checked); config.setMenuOption(
'options.restartOnConfigChanges',
item.checked,
);
}, },
}, },
{ {
label: 'Reset App cache when app starts', label: 'Reset App cache when app starts',
type: 'checkbox', type: 'checkbox',
checked: config.get('options.autoResetAppCache'), checked: config.get('options.autoResetAppCache'),
click(item) { click(item: MenuItem) {
config.setMenuOption('options.autoResetAppCache', item.checked); config.setMenuOption('options.autoResetAppCache', item.checked);
}, },
}, },
{ type: 'separator' }, { type: 'separator' },
is.macOS() is.macOS()
? { ? {
label: 'Toggle DevTools', label: 'Toggle DevTools',
// Cannot use "toggleDevTools" role in macOS // Cannot use "toggleDevTools" role in macOS
click() { click() {
const { webContents } = win; const { webContents } = win;
if (webContents.isDevToolsOpened()) { if (webContents.isDevToolsOpened()) {
webContents.closeDevTools(); webContents.closeDevTools();
} else { } else {
webContents.openDevTools(); webContents.openDevTools();
} }
}, },
} }
: { role: 'toggleDevTools' }, : { role: 'toggleDevTools' },
{ {
label: 'Edit config.json', label: 'Edit config.json',
@ -377,8 +394,14 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
{ role: 'reload' }, { role: 'reload' },
{ role: 'forceReload' }, { role: 'forceReload' },
{ type: 'separator' }, { 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' }, { role: 'resetZoom' },
{ type: 'separator' }, { type: 'separator' },
{ role: 'togglefullscreen' }, { role: 'togglefullscreen' },
@ -419,14 +442,12 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
}, },
{ {
label: 'About', label: 'About',
submenu: [ submenu: [{ role: 'about' }],
{ role: 'about' }, },
],
}
]; ];
}; };
export const setApplicationMenu = async (win: Electron.BrowserWindow) => { export const setApplicationMenu = (win: Electron.BrowserWindow) => {
const menuTemplate: MenuTemplate = [...await mainMenuTemplate(win)]; const menuTemplate: MenuTemplate = [...mainMenuTemplate(win)];
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
const { name } = app; const { name } = app;
menuTemplate.unshift({ menuTemplate.unshift({
@ -455,23 +476,27 @@ export const setApplicationMenu = async (win: Electron.BrowserWindow) => {
}; };
async function setProxy(item: Electron.MenuItem, win: BrowserWindow) { async function setProxy(item: Electron.MenuItem, win: BrowserWindow) {
const output = await prompt({ const output = await prompt(
title: 'Set Proxy', {
label: 'Enter Proxy Address: (leave empty to disable)', title: 'Set Proxy',
value: config.get('options.proxy'), label: 'Enter Proxy Address: (leave empty to disable)',
type: 'input', value: config.get('options.proxy'),
inputAttrs: { type: 'input',
type: 'url', inputAttrs: {
placeholder: "Example: 'socks5://127.0.0.1:9999", type: 'url',
placeholder: "Example: 'socks5://127.0.0.1:9999",
},
width: 450,
...promptOptions(),
}, },
width: 450, win,
...promptOptions(), );
}, win);
if (typeof output === 'string') { if (typeof output === 'string') {
config.setMenuOption('options.proxy', output); config.setMenuOption('options.proxy', output);
item.checked = output !== ''; item.checked = output !== '';
} else { // User pressed cancel } else {
// User pressed cancel
item.checked = !item.checked; // Reset checkbox 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 style from './style.css?inline';
import { createPluginBuilder } from '../utils/builder'; export default createPlugin({
const builder = createPluginBuilder('blur-nav-bar', {
name: 'Blur Navigation Bar', name: 'Blur Navigation Bar',
restartNeeded: true, renderer: { stylesheet: style },
config: {
enabled: false,
},
styles: [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', name: 'Bypass Age Restrictions',
restartNeeded: true, 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', name: 'Captions Selector',
restartNeeded: false,
config: { config: {
enabled: false,
disableCaptions: false, disableCaptions: false,
autoload: false, autoload: false,
lastCaptionsCode: '', 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 './css';
export * from './fs'; export * from './fs';
export * from './plugin';
export * from './types'; export * from './types';
export * from './fetch'; 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 { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
import is from 'electron-is'; import is from 'electron-is';
import { pluginBuilders } from 'virtual:PluginBuilders';
import { preloadPlugins } from 'virtual:PreloadPlugins';
import config from './config'; import config from './config';
import {
PluginBaseConfig,
PluginBuilder,
PreloadPluginFactory
} from './plugins/utils/builder';
import { import {
forceLoadPreloadPlugin, forceLoadPreloadPlugin,
forceUnloadPreloadPlugin, forceUnloadPreloadPlugin,
loadAllPreloadPlugins, loadAllPreloadPlugins,
registerPreloadPlugin
} from './loader/preload'; } 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(); loadAllPreloadPlugins();
ipcRenderer.on('plugin:unload', (_, id: keyof PluginBuilderList) => { ipcRenderer.on('plugin:unload', (_, id: keyof PluginBuilderList) => {
@ -33,19 +18,34 @@ ipcRenderer.on('plugin:enable', (_, id: keyof PluginBuilderList) => {
forceLoadPreloadPlugin(id); forceLoadPreloadPlugin(id);
}); });
contextBridge.exposeInMainWorld('mainConfig', config); contextBridge.exposeInMainWorld('mainConfig', config);
contextBridge.exposeInMainWorld('electronIs', is); contextBridge.exposeInMainWorld('electronIs', is);
contextBridge.exposeInMainWorld('ipcRenderer', { contextBridge.exposeInMainWorld('ipcRenderer', {
on: (channel: string, listener: (event: IpcRendererEvent, ...args: unknown[]) => void) => ipcRenderer.on(channel, listener), on: (
off: (channel: string, listener: (...args: unknown[]) => void) => ipcRenderer.off(channel, listener), channel: string,
once: (channel: string, listener: (event: IpcRendererEvent, ...args: unknown[]) => void) => ipcRenderer.once(channel, listener), listener: (event: IpcRendererEvent, ...args: unknown[]) => void,
send: (channel: string, ...args: unknown[]) => ipcRenderer.send(channel, ...args), ) => ipcRenderer.on(channel, listener),
removeListener: (channel: string, listener: (...args: unknown[]) => void) => ipcRenderer.removeListener(channel, listener), off: (channel: string, listener: (...args: unknown[]) => void) =>
removeAllListeners: (channel: string) => ipcRenderer.removeAllListeners(channel), ipcRenderer.off(channel, listener),
invoke: async (channel: string, ...args: unknown[]): Promise<unknown> => ipcRenderer.invoke(channel, ...args), once: (
sendSync: (channel: string, ...args: unknown[]): unknown => ipcRenderer.sendSync(channel, ...args), channel: string,
sendToHost: (channel: string, ...args: unknown[]) => ipcRenderer.sendToHost(channel, ...args), 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('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 { rendererPlugins } from 'virtual:plugins';
import { pluginBuilders } from 'virtual:PluginBuilders';
import { PluginBaseConfig, PluginBuilder, RendererPluginFactory } from './plugins/utils/builder'; import {
PluginBaseConfig,
PluginBuilder,
RendererPluginFactory,
} from '@/plugins/utils/builder';
import { startingPages } from './providers/extracted-data'; import { startingPages } from './providers/extracted-data';
import setupSongInfo from './providers/song-info-front'; import setupSongInfo from './providers/song-info-front';
import { import {
forceLoadRendererPlugin, forceLoadRendererPlugin,
forceUnloadRendererPlugin, forceUnloadRendererPlugin,
getAllLoadedRendererPlugins, getLoadedRendererPlugin, getAllLoadedRendererPlugins,
getLoadedRendererPlugin,
loadAllRendererPlugins, loadAllRendererPlugins,
registerRendererPlugin
} from './loader/renderer'; } from './loader/renderer';
import type { YoutubePlayer } from './types/youtube-player'; import type { YoutubePlayer } from '@/types/youtube-player';
let api: (Element & YoutubePlayer) | null = null; 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 { interface YouTubeMusicAppElement extends HTMLElement {
@ -75,17 +81,14 @@ function onApiLoaded() {
{ passive: true }, { passive: true },
); );
Object.values(getAllLoadedRendererPlugins())
.forEach((plugin) => {
plugin.onPlayerApiReady?.(api!);
});
window.ipcRenderer.send('ytmd:player-api-loaded'); window.ipcRenderer.send('ytmd:player-api-loaded');
// Navigate to "Starting page" // Navigate to "Starting page"
const startingPage: string = window.mainConfig.get('options.startingPage'); const startingPage: string = window.mainConfig.get('options.startingPage');
if (startingPage && startingPages[startingPage]) { if (startingPage && startingPages[startingPage]) {
document.querySelector<YouTubeMusicAppElement>('ytmusic-app')?.navigate_(startingPages[startingPage]); document
.querySelector<YouTubeMusicAppElement>('ytmusic-app')
?.navigate_(startingPages[startingPage]);
} }
// Remove upgrade button // Remove upgrade button
@ -98,51 +101,54 @@ function onApiLoaded() {
} }
// Hide / Force show like buttons // Hide / Force show like buttons
const likeButtonsOptions: string = window.mainConfig.get('options.likeButtons'); const likeButtonsOptions: string = window.mainConfig.get(
'options.likeButtons',
);
if (likeButtonsOptions) { if (likeButtonsOptions) {
const likeButtons: HTMLElement | null = document.querySelector('ytmusic-like-button-renderer'); const likeButtons: HTMLElement | null = document.querySelector(
'ytmusic-like-button-renderer',
);
if (likeButtons) { if (likeButtons) {
likeButtons.style.display likeButtons.style.display =
= { {
hide: 'none', hide: 'none',
force: 'inherit', force: 'inherit',
}[likeButtonsOptions] || ''; }[likeButtonsOptions] || '';
} }
} }
} }
(async () => { (() => {
Object.entries(pluginBuilders).forEach(([id, builder]) => { loadAllRendererPlugins();
const typedBuilder = builder as PluginBuilder<string, PluginBaseConfig>;
const plugin = rendererPlugins[id] as RendererPluginFactory<PluginBaseConfig> | undefined;
registerRendererPlugin(id, typedBuilder, plugin); window.ipcRenderer.on(
}); 'plugin:unload',
await loadAllRendererPlugins(); (_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) => { window.ipcRenderer.on(
forceUnloadRendererPlugin(id); 'config-changed',
}); (_event, id: string, newConfig: PluginBaseConfig) => {
window.ipcRenderer.on('plugin:enable', async (_event, id: keyof PluginBuilderList) => { const plugin = getAllLoadedRendererPlugins()[id];
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);
});
// Wait for complete load of YouTube api // Wait for complete load of YouTube api
listenForApiLoad(); listenForApiLoad();
// Blocks the "Are You Still There?" popup by setting the last active time to Date.now every 15min // 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 // Setup back to front logger
if (window.electronIs.dev()) { 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' { declare module 'virtual:plugins' {
import type { MainPluginFactory, PluginBaseConfig } from './plugins/utils/builder'; 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": { "compilerOptions": {
"target": "ESNext", "target": "ESNext",
"lib": ["dom", "dom.iterable", "es2022"], "lib": ["dom", "dom.iterable", "es2022"],
@ -8,15 +9,17 @@
"esModuleInterop": true, "esModuleInterop": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"moduleResolution": "node", "moduleResolution": "node",
"baseUrl": "./src", "baseUrl": ".",
"outDir": "./dist", "outDir": "./dist",
"strict": true, "strict": true,
"noImplicitAny": true, "noImplicitAny": true,
"strictFunctionTypes": true, "strictFunctionTypes": true,
"skipLibCheck": true "skipLibCheck": true,
"paths": {
"@/*": ["./src/*"],
"@assets": ["./assets/*"]
}
}, },
"exclude": ["./dist"], "exclude": ["./dist"],
"paths": { "include": ["electron.vite.config.ts", "./src/**/*"]
"*": ["*.d.ts"]
}
} }

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