3baba3b7bc
Replace the hand-rolled `new Promise((resolve) => execFile(…, cb))` wrappers in
probe / ytdlp (×2) / ffmpeg / indexer.probeFlat / download.probeMeta with one
lib/exec.ts execFileAsync returning a normalized
{ok,stdout,stderr,timedOut,error} that never rejects. Each caller keeps its own
error mapping (timedOut -> "Timed out …" vs stderr) and JSON parsing; behaviour
is unchanged. windowsHide defaults on; per-call timeout/maxBuffer preserved.
Foundation for CC4/CC5 — the stdout line-buffer half lands with SIMP5, the
net.request half with SIMP16, at which point those checkboxes close.
Unit-tested (exec.test.ts) against the node binary: ok / stderr+nonzero-exit /
missing-binary / timeout. typecheck + 262 tests + eslint + prettier green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
131 lines
4.8 KiB
TypeScript
131 lines
4.8 KiB
TypeScript
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<YtdlpVersionResult> {
|
|
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 <channel>`
|
|
* (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<YtdlpUpdateResult> {
|
|
// 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<void> {
|
|
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()
|
|
})
|
|
}
|