/** * Built-in yt-dlp terminal (Phase N): runs the bundled yt-dlp with raw, user-typed * arguments and streams stdout/stderr back to the renderer line-by-line. * * SECURITY: the executable is fixed to the managed yt-dlp (never an arbitrary exe), * and the whole feature is gated on Settings.customCommandEnabled — the same consent * flag that guards per-download extra args, because yt-dlp args can run arbitrary * code (`--exec`). The gate is enforced here in main, not just in the renderer UI. */ import { spawn, execFile, type ChildProcess } from 'child_process' import { existsSync } from 'fs' import { type WebContents } from 'electron' import { getYtdlpPath, getBinDir, getSystem32Path } from './binaries' import { getSettings } from './settings' import { ensureManagedYtdlp } from './ytdlp' import { isYtdlpUpdating, acquireSpawn, releaseSpawn } from './ytdlpLock' import { parseExtraArgs } from './buildArgs' import { createLineBuffer } from './lib/lineBuffer' import { IpcChannels, type TerminalEvent, type TerminalRunResult } from '@shared/ipc' const active = new Map() function send(wc: WebContents, ev: TerminalEvent): void { if (!wc.isDestroyed()) wc.send(IpcChannels.terminalOutput, ev) } export function runTerminal(wc: WebContents, id: string, argsRaw: string): TerminalRunResult { if (!getSettings().customCommandEnabled) { return { ok: false, error: 'Enable "Run custom commands" in Settings → Custom commands to use the terminal.' } } ensureManagedYtdlp() const ytdlp = getYtdlpPath() if (!existsSync(ytdlp)) { return { ok: false, error: 'yt-dlp.exe is missing and could not be restored.' } } if (active.has(id)) return { ok: false, error: 'A command is already running.' } // L139: same exe-rewrite interlock as downloads — don't launch yt-dlp while a // self-update is replacing the binary. if (isYtdlpUpdating()) { return { ok: false, error: 'yt-dlp is updating — try again in a moment.' } } // Always pin ffmpeg so post-processing works; the rest is whatever the user typed. const args = ['--ffmpeg-location', getBinDir(), ...parseExtraArgs(argsRaw)] let child: ChildProcess try { child = spawn(ytdlp, args, { windowsHide: true }) } catch (e) { return { ok: false, error: (e as Error).message } } active.set(id, child) acquireSpawn() // L139: live yt-dlp process — the updater must defer to it // Buffer each stream and emit on newline boundaries (flush the remainder on close). const lineBufs = { stdout: createLineBuffer((line) => send(wc, { type: 'output', id, line, stream: 'stdout' })), stderr: createLineBuffer((line) => send(wc, { type: 'output', id, line, stream: 'stderr' })) } child.stdout?.on('data', (c: Buffer) => lineBufs.stdout.push(c.toString())) child.stderr?.on('data', (c: Buffer) => lineBufs.stderr.push(c.toString())) let settled = false child.on('error', (err) => { if (settled) return settled = true active.delete(id) releaseSpawn() send(wc, { type: 'error', id, error: err.message }) }) child.on('close', (code) => { if (settled) return settled = true active.delete(id) releaseSpawn() // Flush any trailing partial lines that never hit a newline. lineBufs.stdout.flush() lineBufs.stderr.flush() send(wc, { type: 'done', id, code }) }) return { ok: true } } export function cancelTerminal(id: string): void { const child = active.get(id) if (!child) return const pid = child.pid if (pid != null) { // Tree-kill via System32 taskkill by absolute path (same hardening as download.ts). execFile( getSystem32Path('taskkill.exe'), ['/pid', String(pid), '/T', '/F'], { windowsHide: true }, () => {} ) } else { child.kill() } }