import { execFile } from 'child_process' import { existsSync, mkdirSync, copyFileSync } from 'fs' import { dirname } from 'path' import { getYtdlpPath, getBundledYtdlpPath, YTDLP_MISSING_MSG } from './binaries' import { getSettings, setSettings } from './settings' import { shouldAutoCheckYtdlp } from './ytdlpPolicy' import { isYtdlpUpdateChannel, type YtdlpVersionResult, type YtdlpUpdateChannel, type YtdlpUpdateResult, type YtdlpAutoUpdateStatus } from '@shared/ipc' /** * Ensure the writable managed yt-dlp.exe exists, copying the bundled seed in if * it doesn't (first run, or after the user/AV deleted it). Best-effort and * idempotent — when the copy is already present this is just one existsSync, so * it's cheap to call before any spawn/update. Does nothing if the seed itself is * gone (a broken install); callers surface their own missing-binary error then. */ export function ensureManagedYtdlp(): void { const managed = getYtdlpPath() if (existsSync(managed)) return const seed = getBundledYtdlpPath() if (!existsSync(seed)) return try { mkdirSync(dirname(managed), { recursive: true }) copyFileSync(seed, managed) } catch { /* best-effort; a failed seed just leaves the managed copy absent */ } } /** Spawn the bundled yt-dlp and read back its `--version` for the Settings panel. */ export function getYtdlpVersion(): Promise { const ytdlpPath = getYtdlpPath() if (!existsSync(ytdlpPath)) { return Promise.resolve({ ok: false, error: YTDLP_MISSING_MSG }) } return new Promise((resolve) => { execFile( ytdlpPath, ['--version'], { windowsHide: true, timeout: 15_000 }, (err, stdout, stderr) => { if (err) { const msg = (err as { killed?: boolean }).killed ? 'Timed out running yt-dlp.' : (stderr || err.message).trim() resolve({ ok: false, error: msg }) return } resolve({ ok: true, version: stdout.trim() }) } ) }) } /** * Self-update the bundled yt-dlp.exe via its built-in `--update-to ` * (stable releases or the nightly build). Requires write access to the file * yt-dlp.exe lives in, which holds for both the dev resources/bin checkout and * a per-user install; it would fail under a locked-down system install. */ export function updateYtdlp(channel: YtdlpUpdateChannel): Promise { // Validate against the channel allowlist BEFORE the value reaches `--update-to`. // That flag also accepts `OWNER/REPO@TAG`, which would download and install an // arbitrary binary over yt-dlp.exe — so an unrecognised value (e.g. forged by a // compromised renderer over IPC) must never be forwarded. (audit F1) if (!isYtdlpUpdateChannel(channel)) { return Promise.resolve({ ok: false, error: 'Unsupported update channel.' }) } // Self-heal: restore the managed copy from the bundled seed before updating, so // a deleted yt-dlp.exe is re-seeded and then updated in one click. ensureManagedYtdlp() const ytdlpPath = getYtdlpPath() if (!existsSync(ytdlpPath)) { return Promise.resolve({ ok: false, error: YTDLP_MISSING_MSG }) } return new Promise((resolve) => { execFile( ytdlpPath, ['--update-to', channel], { windowsHide: true, timeout: 60_000 }, (err, stdout, stderr) => { if (err) { const msg = (err as { killed?: boolean }).killed ? 'Timed out updating yt-dlp.' : (stderr || err.message).trim() resolve({ ok: false, error: msg }) return } resolve({ ok: true, output: stdout.trim() }) } ) }) } /** * Startup yt-dlp maintenance: always seed the managed copy, then — if auto-update * is on and the daily throttle has elapsed — self-update it to the configured * channel. Whether it changed is decided by comparing `--version` before/after * (robust against yt-dlp's localized "up to date" wording). Status is pushed to * the renderer so the Settings UI reflects a check it didn't trigger. * * Best-effort: every failure path still records the attempt time (so an * unreachable update server doesn't re-hit the network every launch) and never * throws — a stale binary must never block the app from starting. */ export async function runStartupYtdlpAutoUpdate( send: (status: YtdlpAutoUpdateStatus) => void ): Promise { ensureManagedYtdlp() const settings = getSettings() if (!shouldAutoCheckYtdlp(settings.autoUpdateYtdlp, settings.ytdlpLastUpdateCheck, Date.now())) { return } const channel = settings.ytdlpChannel send({ phase: 'checking', channel }) const before = await getYtdlpVersion() const result = await updateYtdlp(channel) setSettings({ ytdlpLastUpdateCheck: Date.now() }) if (!result.ok) { send({ phase: 'error', channel, error: result.error, checkedAt: Date.now() }) return } const after = await getYtdlpVersion() const changed = before.ok && after.ok && before.version !== after.version send({ phase: changed ? 'updated' : 'current', channel, version: after.ok ? after.version : undefined, checkedAt: Date.now() }) }