814ecac287
The contained consistency consolidations; the sweeping ones are deferred with their standard documented (they're the audit's own post-1.0 refactors, and several would be reckless to do unattended). Landed: - src/main/lib/lineBuffer.ts (createLineBuffer): one newline line-splitter for streamed child-process output, replacing the duplicated buffer loop in download.ts and terminal.ts (CC3/CC4/SIMP5). Pure + unit-tested (test/lineBuffer.test.ts, 7 cases: partial lines, CRLF, empty lines, flush, multi-line chunks). download.ts keeps byte-identical marker parsing and no close-flush (yt-dlp template lines are always newline-terminated). - ytdlp.ts: getYtdlpVersion/updateYtdlp now run stderr through cleanError, the same `cleanError(r.stderr) || r.error?.message || ''` idiom already used by download/probe/indexer (CC6) — a failed check shows the trailing error line. - CL6 resolved: the one real redundancy was L15; clearDir/openFile/showInFolder are deliberate view-model wrappers (CC13), left as-is. Deferred with documented standard + rationale (CODE-AUDIT.md): CC1 (breaking persisted-key rename → migration), CC5+CL4 (security-critical updater net.request, not live-verifiable here), CC7 (cosmetic arg-order), CC9 (zod dep), CC10 (electron-store↔jsonStore split is deliberate for DPAPI), CC11 (config.ts move), CC12 (file-tree reorg), CC13→L93, CC14→L142. Verified: typecheck (node+web) + 275 tests + eslint + production build green; touched files prettier-clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
134 lines
5.0 KiB
TypeScript
134 lines
5.0 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 { cleanError } from './log'
|
|
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) {
|
|
// Run stderr through the shared cleaner so yt-dlp's noisy output collapses to
|
|
// its trailing error line, consistent with download/probe/indexer (CC6).
|
|
const msg = r.timedOut
|
|
? 'Timed out running yt-dlp.'
|
|
: cleanError(r.stderr) || r.error?.message || ''
|
|
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.'
|
|
: cleanError(r.stderr) || r.error?.message || ''
|
|
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()
|
|
})
|
|
}
|