diff --git a/src/i18n/resources/de.json b/src/i18n/resources/de.json index 8f0d41fa15..11fc77d02b 100644 --- a/src/i18n/resources/de.json +++ b/src/i18n/resources/de.json @@ -288,7 +288,7 @@ "name": "Ambiente-Modus" }, "amuse": { - "description": "Fügt Unterstützung für das Amuse \"Spielt gerade\"-Widget von 6K Labs hinzu", + "description": "Fügt {{applicationName}} Unterstützung für das Amuse \"Spielt gerade\"-Widget von 6K Labs hinzu", "name": "Amuse", "response": { "query": "Amuse API-Server läuft. /query für Liedinformationen." diff --git a/src/index.ts b/src/index.ts index 80fae3db0f..b3e993fd8d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -126,6 +126,7 @@ if (config.get('options.disableHardwareAcceleration')) { } if (is.linux()) { + app.commandLine.appendSwitch('enable-transparent-visuals'); // Overrides WM_CLASS for X11 to correspond to icon filename app.setName( 'com.github.th_ch.\u0079\u006f\u0075\u0074\u0075\u0062\u0065\u005f\u006d\u0075\u0073\u0069\u0063', @@ -163,11 +164,12 @@ electronDebug({ showDevTools: false, // Disable automatic devTools on new window }); -let icon = 'assets/icon.png'; +const iconPath = app.getAppPath(); +let icon = path.join(iconPath, 'assets/icon.png'); if (process.platform === 'win32') { - icon = 'assets/generated/icons/win/icon.ico'; + icon = path.join(iconPath, 'assets/generated/icons/win/icon.ico'); } else if (process.platform === 'darwin') { - icon = 'assets/generated/icons/mac/icon.icns'; + icon = path.join(iconPath, 'assets/generated/icons/mac/icon.icns'); } function onClosed() { @@ -323,6 +325,8 @@ async function createMainWindow() { const windowMaximized = config.get('window-maximized'); const windowPosition: Electron.Point = config.get('window-position'); const useInlineMenu = await config.plugins.isEnabled('in-app-menu'); + const isTransparentPlayerEnabled = + await config.plugins.isEnabled('transparent-player'); const defaultTitleBarOverlayOptions: Electron.TitleBarOverlay = { color: '#00000000', @@ -349,11 +353,14 @@ async function createMainWindow() { const electronWindowSettings: Electron.BrowserWindowConstructorOptions = { icon, + title: 'Youtube Music', width: windowSize.width, height: windowSize.height, minWidth: 325, minHeight: 425, - backgroundColor: '#000', + backgroundColor: + is.linux() && isTransparentPlayerEnabled ? '#00000000' : '#000', + transparent: is.linux() && isTransparentPlayerEnabled, show: false, webPreferences: { contextIsolation: true, diff --git a/src/plugins/adblocker/.gitignore b/src/plugins/adblocker/.gitignore new file mode 100644 index 0000000000..fa9765b905 --- /dev/null +++ b/src/plugins/adblocker/.gitignore @@ -0,0 +1 @@ +/ad-blocker-engine.bin \ No newline at end of file diff --git a/src/plugins/adblocker/blocker.ts b/src/plugins/adblocker/blocker.ts new file mode 100644 index 0000000000..c3957f93fa --- /dev/null +++ b/src/plugins/adblocker/blocker.ts @@ -0,0 +1,80 @@ +// Used for caching +import path from 'node:path'; +import fs, { promises } from 'node:fs'; + +import { ElectronBlocker } from '@ghostery/adblocker-electron'; +import { app, net } from 'electron'; + +const SOURCES = [ + 'https://raw.githubusercontent.com/kbinani/adblock-youtube-ads/master/signed.txt', + // UBlock Origin + 'https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/filters.txt', + 'https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/quick-fixes.txt', + 'https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/unbreak.txt', + 'https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/filters-2020.txt', + 'https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/filters-2021.txt', + 'https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/filters-2022.txt', + 'https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/filters-2023.txt', + // Fanboy Annoyances + 'https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt', + // AdGuard + 'https://filters.adtidy.org/extension/ublock/filters/122_optimized.txt', +]; + +let blocker: ElectronBlocker | undefined; + +export const loadAdBlockerEngine = async ( + session: Electron.Session | undefined = undefined, + cache: boolean = true, + additionalBlockLists: string[] = [], + disableDefaultLists: boolean | unknown[] = false, +) => { + // Only use cache if no additional blocklists are passed + const cacheDirectory = path.join(app.getPath('userData'), 'adblock_cache'); + if (!fs.existsSync(cacheDirectory)) { + fs.mkdirSync(cacheDirectory); + } + const cachingOptions = + cache && additionalBlockLists.length === 0 + ? { + path: path.join(cacheDirectory, 'adblocker-engine.bin'), + read: promises.readFile, + write: promises.writeFile, + } + : undefined; + const lists = [ + ...((disableDefaultLists && !Array.isArray(disableDefaultLists)) || + (Array.isArray(disableDefaultLists) && disableDefaultLists.length > 0) + ? [] + : SOURCES), + ...additionalBlockLists, + ]; + + try { + blocker = await ElectronBlocker.fromLists( + (url: string) => net.fetch(url), + lists, + { + // When generating the engine for caching, do not load network filters + // So that enhancing the session works as expected + // Allowing to define multiple webRequest listeners + loadNetworkFilters: session !== undefined, + }, + cachingOptions, + ); + if (session) { + blocker.enableBlockingInSession(session); + } + } catch (error) { + console.log('Error loading adBlocker engine', error); + } +}; + +export const unloadAdBlockerEngine = (session: Electron.Session) => { + if (blocker) { + blocker.disableBlockingInSession(session); + } +}; + +export const isBlockerEnabled = (session: Electron.Session) => + blocker !== undefined && blocker.isBlockingEnabled(session); \ No newline at end of file diff --git a/src/plugins/adblocker/index.ts b/src/plugins/adblocker/index.ts new file mode 100644 index 0000000000..bbc9f07b8c --- /dev/null +++ b/src/plugins/adblocker/index.ts @@ -0,0 +1,133 @@ +import { contextBridge, webFrame } from 'electron'; + +import { blockers } from './types'; +import { createPlugin } from '@/utils'; +import { + isBlockerEnabled, + loadAdBlockerEngine, + unloadAdBlockerEngine, +} from './blocker'; + +import injectCliqzPreload from './injectors/inject-cliqz-preload'; +import { inject, isInjected } from './injectors/inject'; + +import { t } from '@/i18n'; + +import type { BrowserWindow } from 'electron'; + +interface AdblockerConfig { + /** + * Whether to enable the adblocker. + * @default true + */ + enabled: boolean; + /** + * When enabled, the adblocker will cache the blocklists. + * @default true + */ + cache: boolean; + /** + * Which adblocker to use. + * @default blockers.InPlayer + */ + blocker: (typeof blockers)[keyof typeof blockers]; + /** + * Additional list of filters to use. + * @example ["https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt"] + * @default [] + */ + additionalBlockLists: string[]; + /** + * Disable the default blocklists. + * @default false + */ + disableDefaultLists: boolean; +} + +export default createPlugin({ + name: () => t('plugins.adblocker.name'), + description: () => t('plugins.adblocker.description'), + restartNeeded: false, + config: { + enabled: true, + cache: true, + blocker: blockers.InPlayer, + additionalBlockLists: [], + disableDefaultLists: false, + } as AdblockerConfig, + menu: async ({ getConfig, setConfig }) => { + const config = await getConfig(); + + return [ + { + label: t('plugins.adblocker.menu.blocker'), + submenu: Object.values(blockers).map((blocker) => ({ + label: blocker, + type: 'radio', + checked: (config.blocker || blockers.WithBlocklists) === blocker, + click() { + setConfig({ blocker }); + }, + })), + }, + ]; + }, + backend: { + mainWindow: null as BrowserWindow | null, + async start({ getConfig, window }) { + const config = await getConfig(); + this.mainWindow = window; + + if (config.blocker === blockers.WithBlocklists) { + await loadAdBlockerEngine( + window.webContents.session, + config.cache, + config.additionalBlockLists, + config.disableDefaultLists, + ); + } + }, + stop({ window }) { + if (isBlockerEnabled(window.webContents.session)) { + unloadAdBlockerEngine(window.webContents.session); + } + }, + async onConfigChange(newConfig) { + if (this.mainWindow) { + if ( + newConfig.blocker === blockers.WithBlocklists && + !isBlockerEnabled(this.mainWindow.webContents.session) + ) { + await loadAdBlockerEngine( + this.mainWindow.webContents.session, + newConfig.cache, + newConfig.additionalBlockLists, + newConfig.disableDefaultLists, + ); + } + } + }, + }, + preload: { + script: 'window.JSON.parse = window._proxyJsonParse; window._proxyJsonParse = undefined; window.Response.prototype.json = window._proxyResponseJson; window._proxyResponseJson = undefined; 0', + async start({ getConfig }) { + const config = await getConfig(); + + if (config.blocker === blockers.WithBlocklists) { + // Preload adblocker to inject scripts/styles + await injectCliqzPreload(); + } else if (config.blocker === blockers.InPlayer && !isInjected()) { + inject(contextBridge); + await webFrame.executeJavaScript(this.script); + } + }, + async onConfigChange(newConfig) { + if (newConfig.blocker === blockers.WithBlocklists) { + await injectCliqzPreload(); + } else if (newConfig.blocker === blockers.InPlayer && !isInjected()) { + inject(contextBridge); + await webFrame.executeJavaScript(this.script); + } + }, + }, +}); \ No newline at end of file diff --git a/src/plugins/adblocker/injectors/inject-cliqz-preload.ts b/src/plugins/adblocker/injectors/inject-cliqz-preload.ts new file mode 100644 index 0000000000..4a53c3e0d1 --- /dev/null +++ b/src/plugins/adblocker/injectors/inject-cliqz-preload.ts @@ -0,0 +1,3 @@ +export default async () => { + await import('@ghostery/adblocker-electron-preload'); +}; \ No newline at end of file diff --git a/src/plugins/adblocker/injectors/inject.d.ts b/src/plugins/adblocker/injectors/inject.d.ts new file mode 100644 index 0000000000..95d52ef3a1 --- /dev/null +++ b/src/plugins/adblocker/injectors/inject.d.ts @@ -0,0 +1,5 @@ +import type { ContextBridge } from 'electron'; + +export const inject: (contextBridge: ContextBridge) => void; + +export const isInjected: () => boolean; \ No newline at end of file diff --git a/src/plugins/adblocker/injectors/inject.js b/src/plugins/adblocker/injectors/inject.js new file mode 100644 index 0000000000..99cfc33008 --- /dev/null +++ b/src/plugins/adblocker/injectors/inject.js @@ -0,0 +1,269 @@ +/* eslint-disable */ + +// Source: https://addons.mozilla.org/en-US/firefox/addon/adblock-for-youtube/ +// https://robwu.nl/crxviewer/?crx=https%3A%2F%2Faddons.mozilla.org%2Fen-US%2Ffirefox%2Faddon%2Fadblock-for-youtube%2F + +/* + Parts of this code is derived from set-constant.js: + https://github.com/gorhill/uBlock/blob/5de0ce975753b7565759ac40983d31978d1f84ca/assets/resources/scriptlets.js#L704 + */ + +let injected = false; + +export const isInjected = () => injected; + +/** + * @param {Electron.ContextBridge} contextBridge + * @returns {*} + */ +export const inject = (contextBridge) => { + injected = true; + { + const pruner = function (o) { + delete o.playerAds; + delete o.adPlacements; + delete o.adSlots; + // + if (o.playerResponse) { + delete o.playerResponse.playerAds; + delete o.playerResponse.adPlacements; + delete o.playerResponse.adSlots; + } + if (o.ytInitialPlayerResponse) { + delete o.ytInitialPlayerResponse.playerAds; + delete o.ytInitialPlayerResponse.adPlacements; + delete o.ytInitialPlayerResponse.adSlots; + } + + // + return o; + }; + + contextBridge.exposeInMainWorld('_proxyJsonParse', new Proxy(JSON.parse, { + apply() { + return pruner(Reflect.apply(...arguments)); + }, + })); + + contextBridge.exposeInMainWorld('_proxyResponseJson', new Proxy(Response.prototype.json, { + apply() { + return Reflect.apply(...arguments).then((o) => pruner(o)); + }, + })); + } + + const chains = [ + { + chain: 'playerResponse.adPlacements', + cValue: 'undefined', + }, + { + chain: 'ytInitialPlayerResponse.playerAds', + cValue: 'undefined', + }, + { + chain: 'ytInitialPlayerResponse.adPlacements', + cValue: 'undefined', + }, + { + chain: 'ytInitialPlayerResponse.adSlots', + cValue: 'undefined', + } + ]; + + chains.forEach(function ({ chain, cValue }) { + const thisScript = document.currentScript; + // + switch (cValue) { + case 'null': { + cValue = null; + break; + } + + case "''": { + cValue = ''; + break; + } + + case 'true': { + cValue = true; + break; + } + + case 'false': { + cValue = false; + break; + } + + case 'undefined': { + cValue = undefined; + break; + } + + case 'noopFunc': { + cValue = function () { }; + + break; + } + + case 'trueFunc': { + cValue = function () { + return true; + }; + + break; + } + + case 'falseFunc': { + cValue = function () { + return false; + }; + + break; + } + + default: { + if (/^\d+$/.test(cValue)) { + cValue = Number.parseFloat(cValue); + // + if (isNaN(cValue)) { + return; + } + + if (Math.abs(cValue) > 0x7f_ff) { + return; + } + } else { + return; + } + } + } + + // + let aborted = false; + const mustAbort = function (v) { + if (aborted) { + return true; + } + + aborted = + v !== undefined && + v !== null && + cValue !== undefined && + cValue !== null && + typeof v !== typeof cValue; + return aborted; + }; + + /* + Support multiple trappers for the same property: + https://github.com/uBlockOrigin/uBlock-issues/issues/156 + */ + + const trapProp = function (owner, prop, configurable, handler) { + if (handler.init(owner[prop]) === false) { + return; + } + + // + const odesc = Object.getOwnPropertyDescriptor(owner, prop); + let previousGetter; + let previousSetter; + if (odesc instanceof Object) { + if (odesc.configurable === false) { + return; + } + + if (odesc.get instanceof Function) { + previousGetter = odesc.get; + } + + if (odesc.set instanceof Function) { + previousSetter = odesc.set; + } + } + + // + Object.defineProperty(owner, prop, { + configurable, + get() { + if (previousGetter !== undefined) { + previousGetter(); + } + + // + return handler.getter(); + }, + set(a) { + if (previousSetter !== undefined) { + previousSetter(a); + } + + // + handler.setter(a); + }, + }); + }; + + const trapChain = function (owner, chain) { + const pos = chain.indexOf('.'); + if (pos === -1) { + trapProp(owner, chain, false, { + v: undefined, + getter() { + return document.currentScript === thisScript ? this.v : cValue; + }, + setter(a) { + if (mustAbort(a) === false) { + return; + } + + cValue = a; + }, + init(v) { + if (mustAbort(v)) { + return false; + } + + // + this.v = v; + return true; + }, + }); + // + return; + } + + // + const prop = chain.slice(0, pos); + const v = owner[prop]; + // + chain = chain.slice(pos + 1); + if (v instanceof Object || (typeof v === 'object' && v !== null)) { + trapChain(v, chain); + return; + } + + // + trapProp(owner, prop, true, { + v: undefined, + getter() { + return this.v; + }, + setter(a) { + this.v = a; + if (a instanceof Object) { + trapChain(a, chain); + } + }, + init(v) { + this.v = v; + return true; + }, + }); + }; + + // + trapChain(window, chain); + }); +}; \ No newline at end of file diff --git a/src/plugins/adblocker/types/index.ts b/src/plugins/adblocker/types/index.ts new file mode 100644 index 0000000000..9548d5fc43 --- /dev/null +++ b/src/plugins/adblocker/types/index.ts @@ -0,0 +1,4 @@ +export const blockers = { + WithBlocklists: 'With blocklists', + InPlayer: 'In player', +} as const; \ No newline at end of file diff --git a/src/plugins/transparent-player/index.ts b/src/plugins/transparent-player/index.ts index 38c7e3ac50..2bf6e6b29a 100644 --- a/src/plugins/transparent-player/index.ts +++ b/src/plugins/transparent-player/index.ts @@ -9,7 +9,7 @@ import style from './style.css?inline'; import type { BrowserWindow } from 'electron'; const defaultConfig: TransparentPlayerConfig = { - enabled: false, + enabled: true, opacity: 0.5, type: MaterialType.ACRYLIC, }; @@ -22,7 +22,7 @@ export default createPlugin({ description: () => t('plugins.transparent-player.description'), addedVersion: '3.11.x', restartNeeded: true, - platform: Platform.Windows, + platform: Platform.Windows | Platform.Linux, config: defaultConfig, stylesheets: [style], async menu({ getConfig, setConfig }) {