e19f988c72
The watched live session for the six deferred lifecycle items, each verified against real yt-dlp/Electron behavior on Windows: - R4: cancel now deletes the download's orphaned partials. The engine captures the final output path via a new `--print before_dl:dest|%(filename)s` (empirically %(filepath)s is still 'NA' that early) and the close handler sweeps only unambiguous intermediates (.part / .part-Frag* / .ytdl / <stem>.fNNN.<ext>) via the pure, unit-tested lib/partials.ts — never a bare <stem>.<ext> or another download's files. Live-verified with decoys. - L65: verified-acceptable, no code change — a taskkill /F pause leaves the .part byte-intact and yt-dlp --continue resumes at the exact byte offset. - L139: spawn↔self-update interlock (new ytdlpLock.ts). The updater refuses while any yt-dlp spawn is live; download/terminal IPC handlers hold (await) behind an in-progress exe rewrite instead of failing. - B2: source indexing is cancellable — AbortSignal through the probe walk (kills the in-flight child, live-verified via tasklist), sources:index-cancel IPC, and a Cancel button in the Library add-source row. - B6: app-update download is cancellable — app:update-cancel routes through the existing finish() teardown (abort + destroy + unlink, live-verified on Electron net.request); Cancel button under the update progress bar. The checksum phase is cancelable too. - L143: closing the window mid-download (non-tray mode) now offers a native tray-independent "Quit anyway / Keep downloading" dialog, replacing the passive "use the tray to quit" notification. Peer-review pass caught and fixed: a non-reentrant update lock (concurrent begin clobbered the settle promise), a dead Cancel window during the B6 checksum fetch + a canceller slot-ownership bug (L140 shape), and a persist-after-cancel hole in the index walk. typecheck + 304 tests + eslint + production build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
160 lines
6.3 KiB
TypeScript
160 lines
6.3 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 { beginYtdlpUpdate, endYtdlpUpdate, liveSpawnCount } from './ytdlpLock'
|
|
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 }
|
|
}
|
|
|
|
// L139: `--update-to` rewrites yt-dlp.exe in place. Take the spawn interlock so a
|
|
// download/terminal can't execute the exe mid-swap (Windows sharing violation /
|
|
// half-written binary). If a spawn (or another update) is already live, defer
|
|
// rather than fight it — the update is best-effort; the startup path retries next
|
|
// launch and the Settings "Update now" button surfaces this message for a manual retry.
|
|
if (!beginYtdlpUpdate()) {
|
|
return {
|
|
ok: false,
|
|
error: 'yt-dlp is busy (a download or another update is running) — try again shortly.'
|
|
}
|
|
}
|
|
try {
|
|
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() }
|
|
} finally {
|
|
endYtdlpUpdate()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
// L139: never rewrite the exe while a download/terminal is live (e.g. an auto-
|
|
// resumed persisted-queue item). Skip WITHOUT recording the check so the daily
|
|
// throttle isn't burned on a deferral — retry next launch. Re-checked right before
|
|
// the rewrite too, since the version probe below awaits.
|
|
if (liveSpawnCount() > 0) return
|
|
|
|
const channel = settings.ytdlpChannel
|
|
send({ phase: 'checking', channel })
|
|
|
|
const before = await getYtdlpVersion()
|
|
if (liveSpawnCount() > 0) {
|
|
// A spawn started during the version probe — defer to next launch (no record).
|
|
send({ phase: 'current', channel, version: before.ok ? before.version : undefined })
|
|
return
|
|
}
|
|
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()
|
|
})
|
|
}
|