Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/generated/icons/mac/icon.icns
Binary file not shown.
3 changes: 2 additions & 1 deletion electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ mac:
arch:
- x64
- arm64
icon: assets/generated/icons/mac/icon.icon
# Legacy .icns avoids electron-builder’s Icon Composer path (requires Xcode 26+ / actool).
icon: assets/generated/icons/mac/icon.icns
compression: maximum
win:
icon: assets/generated/icons/win/icon.ico
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@
"virtua": "0.48.5",
"vudio": "2.1.1",
"x11": "2.3.0",
"youtubei.js": "16.0.1",
"youtubei.js": "17.0.1",
"zod": "4.3.6"
},
"devDependencies": {
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 51 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,33 @@ electronDebug({
showDevTools: false, // Disable automatic devTools on new window
});

let icon = 'assets/icon.png';
if (process.platform === 'win32') {
icon = 'assets/generated/icons/win/icon.ico';
} else if (process.platform === 'darwin') {
icon = 'assets/generated/icons/mac/icon.icns';
/** Absolute path so dev (electron-vite preview), packaged app, and cwd all resolve correctly. */
function getResolvedWindowIconPath(): string {
const relativeIcon =
process.platform === 'win32'
? path.join('assets', 'generated', 'icons', 'win', 'icon.ico')
: process.platform === 'darwin'
? path.join('assets', 'generated', 'icons', 'mac', 'icon.icns')
: path.join('assets', 'icon.png');

const candidates = [
path.join(app.getAppPath(), relativeIcon),
path.join(process.cwd(), relativeIcon),
path.resolve(
path.dirname(url.fileURLToPath(import.meta.url)),
'..',
'..',
relativeIcon,
),
];

for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}

return candidates[0];
}

function onClosed() {
Expand Down Expand Up @@ -348,7 +370,7 @@ async function createMainWindow() {
}

const electronWindowSettings: Electron.BrowserWindowConstructorOptions = {
icon,
icon: getResolvedWindowIconPath(),
width: windowSize.width,
height: windowSize.height,
minWidth: 325,
Expand All @@ -358,6 +380,8 @@ async function createMainWindow() {
webPreferences: {
contextIsolation: true,
preload: path.join(__dirname, '..', 'preload', 'preload.cjs'),
// Keep timers/media scheduling steady; helps Web Audio when the window is in the background.
backgroundThrottling: false,
...(isTesting()
? undefined
: {
Expand All @@ -371,6 +395,17 @@ async function createMainWindow() {

const win = new BrowserWindow(electronWindowSettings);

const nudgeRendererAudio = () => {
if (win.isDestroyed()) return;
win.webContents.send('peard:resume-audio-context');
};
win.on('focus', nudgeRendererAudio);
win.on('show', nudgeRendererAudio);

// Enhance session.webRequest before any plugin registers listeners; otherwise
// enableBlockingInSession attaches to the raw API and the replacement below drops it.
removeContentSecurityPolicy();

await initHook(win);
initTheme(win);

Expand Down Expand Up @@ -484,8 +519,6 @@ async function createMainWindow() {
}
});

removeContentSecurityPolicy();

win.webContents.on('dom-ready', () => {
if (useInlineMenu && is.windows()) {
win.setTitleBarOverlay({
Expand Down Expand Up @@ -794,10 +827,16 @@ app.whenReady().then(async () => {
mainWindow.focus();
});

// Autostart at login
app.setLoginItemSettings({
openAtLogin: config.get('options.startAtLogin'),
});
// Autostart at login (may fail under sandbox / missing macOS permission)
try {
app.setLoginItemSettings({
openAtLogin: config.get('options.startAtLogin'),
});
} catch (err) {
if (is.dev()) {
console.warn(LoggerPrefix, 'setLoginItemSettings skipped:', err);
}
}

if (!is.dev() && config.get('options.autoUpdates')) {
const updateTimeout = setTimeout(() => {
Expand Down
76 changes: 76 additions & 0 deletions src/plugins/adblocker/backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { readFile, writeFile } from 'node:fs/promises';

import { join } from 'node:path';

import { app, session } from 'electron';
import { ElectronBlocker } from '@ghostery/adblocker-electron';
import is from 'electron-is';

import { LoggerPrefix } from '@/utils';

import type { BackendContext } from '@/types/contexts';

import type { AdblockerPluginConfig } from './config';

let activeBlocker: ElectronBlocker | null = null;

async function loadBlockerEngine(
cfg: AdblockerPluginConfig,
): Promise<ElectronBlocker> {
const fetchImpl = globalThis.fetch.bind(globalThis);

const caching = cfg.cache
? {
path: join(app.getPath('userData'), 'adblocker-engine.bin'),
read: async (p: string) => new Uint8Array(await readFile(p)),
write: async (p: string, buf: Uint8Array) => {
await writeFile(p, Buffer.from(buf));
},
}
: undefined;

let engine = await ElectronBlocker.fromPrebuiltAdsAndTracking(
fetchImpl,
caching,
);

const lists = (cfg.additionalBlockLists ?? []).filter(
(u): u is string => typeof u === 'string' && u.length > 0,
);
if (lists.length > 0) {
const extra = await ElectronBlocker.fromLists(fetchImpl, lists);
engine = ElectronBlocker.merge([engine, extra]);
}

return engine;
}

export function stopBlocker() {
if (!activeBlocker) return;
try {
activeBlocker.disableBlockingInSession(session.defaultSession);
} catch {
/* session may already be torn down */
}
activeBlocker = null;
}

export async function startBlocker(cfg: AdblockerPluginConfig) {
if (!cfg.enabled) return;
try {
const engine = await loadBlockerEngine(cfg);
activeBlocker = engine;
engine.enableBlockingInSession(session.defaultSession);
if (is.dev()) {
console.log(LoggerPrefix, 'Adblocker: network blocking enabled');
}
} catch (err) {
console.error(LoggerPrefix, 'Adblocker: failed to start', err);
}
}

export async function startFromContext(
ctx: BackendContext<AdblockerPluginConfig>,
) {
await startBlocker(await ctx.getConfig());
}
6 changes: 6 additions & 0 deletions src/plugins/adblocker/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { PluginConfig } from '@/types/plugins';

export type AdblockerPluginConfig = PluginConfig & {
cache: boolean;
additionalBlockLists: string[];
};
35 changes: 35 additions & 0 deletions src/plugins/adblocker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { createPlugin, createBackend } from '@/utils';
import { t } from '@/i18n';

import { startBlocker, startFromContext, stopBlocker } from './backend';

import type { BackendContext } from '@/types/contexts';

import type { AdblockerPluginConfig } from './config';

export type { AdblockerPluginConfig } from './config';

export default createPlugin({
name: () => t('plugins.adblocker.name'),
description: () => t('plugins.adblocker.description'),
restartNeeded: false,
config: {
enabled: true,
cache: true,
additionalBlockLists: [],
} as AdblockerPluginConfig,
backend: createBackend<{ _noop?: undefined }, AdblockerPluginConfig>({
async start(ctx: BackendContext<AdblockerPluginConfig>) {
await startFromContext(ctx);
},

stop() {
stopBlocker();
},

async onConfigChange(cfg: AdblockerPluginConfig) {
stopBlocker();
await startBlocker(cfg);
},
}),
});
4 changes: 2 additions & 2 deletions src/plugins/api-server/backend/routes/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export const register = (
upgradeWebSocket(() => ({
onOpen(_, ws) {
// "Unsafe argument of type `WSContext<WebSocket>` assigned to a parameter of type `WSContext<WebSocket>`. (@typescript-eslint/no-unsafe-argument)" ????? what?
sockets.add(ws as WSContext<WebSocket>);
sockets.add(ws as unknown as WSContext<WebSocket>);

ws.send(
JSON.stringify({
Expand All @@ -147,7 +147,7 @@ export const register = (
},

onClose(_, ws) {
sockets.delete(ws as WSContext<WebSocket>);
sockets.delete(ws as unknown as WSContext<WebSocket>);
},
})) as (ctx: Context, next: Next) => Promise<Response>,
);
Expand Down
8 changes: 5 additions & 3 deletions src/plugins/auth-proxy-adapter/backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,10 @@ export const backend = createBackend<BackendType, AuthProxyConfig>({
// Create SOCKS proxy server
const socksServer = net.createServer((socket) => {
socket.once('data', (chunk) => {
if (chunk[0] === 0x05) {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (buf[0] === 0x05) {
// SOCKS5
this.handleSocks5(socket, chunk, upstreamProxyUrl);
this.handleSocks5(socket, buf, upstreamProxyUrl);
} else {
socket.end();
}
Expand Down Expand Up @@ -113,7 +114,8 @@ export const backend = createBackend<BackendType, AuthProxyConfig>({

// Wait for client's connection request
clientSocket.once('data', (data) => {
this.processSocks5Request(clientSocket, data, upstreamProxyUrl);
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
this.processSocks5Request(clientSocket, buf, upstreamProxyUrl);
});
} else {
// Authentication methods not supported by the client
Expand Down
11 changes: 10 additions & 1 deletion src/plugins/crossfade/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,17 @@ export default createPlugin<

watchVideoIDChanges(async (videoID) => {
await waitForTransition;
const url = await getStreamURL(videoID);
let url: string | undefined;
try {
url = await getStreamURL(videoID);
} catch {
url = undefined;
}
if (!url) {
const v = document.querySelector<HTMLVideoElement>('video');
if (v && v.volume === 0) {
v.volume = 1;
}
return;
}

Expand Down
32 changes: 15 additions & 17 deletions src/plugins/downloader/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ import type {
TrackInfo,
Playlist,
} from 'node_modules/\u0079\u006f\u0075\u0074\u0075\u0062\u0065i.js/dist/src/parser/ytmusic';
import type {
EvalResult,
VMPrimative,
} from 'node_modules/\u0079\u006f\u0075\u0074\u0075\u0062\u0065i.js/dist/src/types/PlatformShim';
import type { BuildScriptResult } from 'node_modules/\u0079\u006f\u0075\u0074\u0075\u0062\u0065i.js/dist/src/utils/javascript/JsExtractor';

type CustomSongInfo = SongInfo & { trackId?: string };

Expand All @@ -58,21 +63,15 @@ const ffmpeg = lazy(async () =>
);
const ffmpegMutex = new Mutex();

Platform.shim.eval = async (data: Types.BuildScriptResult, env: Record<string, Types.VMPrimative>) => {
const properties = [];

if(env.n) {
properties.push(`n: exportedVars.nFunction("${env.n}")`)
}

if (env.sig) {
properties.push(`sig: exportedVars.sigFunction("${env.sig}")`)
}

const code = `${data.output}\nreturn { ${properties.join(', ')} }`;

return new Function(code)();
}
// youtubei.js ≥17 builds `data.output` with exportedVars + appended `process()` (see getNsigProcessorFn).
// Do not append another return — that breaks nsig handling and crossfade/audio-url.
Platform.shim.eval = (
data: BuildScriptResult,
_env: Record<string, VMPrimative>,
): EvalResult => {
// eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call -- required eval hook
return new Function(data.output)() as EvalResult;
};

let yt: Innertube;
let win: BrowserWindow;
Expand Down Expand Up @@ -421,8 +420,7 @@ async function downloadSongUnsafe(
let targetFileExtension: string;
if (!presetSetting?.extension) {
targetFileExtension =
VideoFormatList.find((it) => it.itag === format.itag)?.container ??
'mp3';
VideoFormatList.find((it) => it.itag === format.itag)?.container ?? 'mp3';
} else {
targetFileExtension = presetSetting?.extension ?? 'mp3';
}
Expand Down
4 changes: 3 additions & 1 deletion src/plugins/skip-silences/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ let playOrSeekHandler: (() => void) | undefined;

const getMaxVolume = (analyser: AnalyserNode, fftBins: Float32Array) => {
let maxVolume = Number.NEGATIVE_INFINITY;
analyser.getFloatFrequencyData(fftBins);
analyser.getFloatFrequencyData(
fftBins as Parameters<AnalyserNode['getFloatFrequencyData']>[0],
);

for (let i = 4, ii = fftBins.length; i < ii; i++) {
if (fftBins[i] > maxVolume && fftBins[i] < 0) {
Expand Down
Loading