import { existsSync, mkdirSync, copyFileSync } from 'fs' import { dirname } from 'path' import { getYtdlpPath, getBundledYtdlpPath, YTDLP_MISSING_MSG } from './binaries' import { execFileAsync } from './lib/exec' import { VERSION_TIMEOUT_MS, YTDLP_UPDATE_TIMEOUT_MS } from './constants' 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 async function getYtdlpVersion(): Promise { const ytdlpPath = getYtdlpPath() if (!existsSync(ytdlpPath)) { return { ok: false, error: YTDLP_MISSING_MSG } } const r = await execFileAsync(ytdlpPath, ['--version'], { timeout: VERSION_TIMEOUT_MS }) if (!r.ok) { const msg = r.timedOut ? 'Timed out running yt-dlp.' : (r.stderr || r.error?.message || '').trim() return { ok: false, error: msg } } return { ok: true, version: r.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 async 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 { 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 { ok: false, error: YTDLP_MISSING_MSG } } const r = await execFileAsync(ytdlpPath, ['--update-to', channel], { timeout: YTDLP_UPDATE_TIMEOUT_MS }) if (!r.ok) { const msg = r.timedOut ? 'Timed out updating yt-dlp.' : (r.stderr || r.error?.message || '').trim() return { ok: false, error: msg } } return { ok: true, output: r.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() }) }