3ae4a5357c
L46: Introduced YTDLP_MISSING_MSG constant in binaries.ts. All four callers
(download, indexer, probe, ytdlp) now emit the same user-facing string:
"yt-dlp.exe is missing. Open Settings -> Software update to re-download it."
This removes newline-embedded messages and four divergent phrasings.
L71: Removed readOnly from videoDir/audioDir folder inputs in SettingsView.
Added onChange handler so users can paste or type a path directly without
being forced to use the Browse button. The path still flows through
applySettings() in main, which validates it.
L74: Accent color swatches were triple-labeled (aria-pressed + aria-label +
title). Replaced with a single aria-label that embeds "(selected)" state,
plus title for the hover tooltip. Removed the incorrect aria-pressed
(these are selection buttons, not toggles).
typecheck + 242 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
152 lines
5.1 KiB
TypeScript
152 lines
5.1 KiB
TypeScript
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<YtdlpVersionResult> {
|
|
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 <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 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 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<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()
|
|
})
|
|
}
|