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>
103 lines
3.7 KiB
TypeScript
103 lines
3.7 KiB
TypeScript
/**
|
|
* Built-in yt-dlp terminal (Phase N): runs the bundled yt-dlp with raw, user-typed
|
|
* arguments and streams stdout/stderr back to the renderer line-by-line.
|
|
*
|
|
* SECURITY: the executable is fixed to the managed yt-dlp (never an arbitrary exe),
|
|
* and the whole feature is gated on Settings.customCommandEnabled — the same consent
|
|
* flag that guards per-download extra args, because yt-dlp args can run arbitrary
|
|
* code (`--exec`). The gate is enforced here in main, not just in the renderer UI.
|
|
*/
|
|
import { spawn, execFile, type ChildProcess } from 'child_process'
|
|
import { existsSync } from 'fs'
|
|
import { type WebContents } from 'electron'
|
|
import { getYtdlpPath, getBinDir, getSystem32Path } from './binaries'
|
|
import { getSettings } from './settings'
|
|
import { ensureManagedYtdlp } from './ytdlp'
|
|
import { isYtdlpUpdating, acquireSpawn, releaseSpawn } from './ytdlpLock'
|
|
import { parseExtraArgs } from './buildArgs'
|
|
import { createLineBuffer } from './lib/lineBuffer'
|
|
import { IpcChannels, type TerminalEvent, type TerminalRunResult } from '@shared/ipc'
|
|
|
|
const active = new Map<string, ChildProcess>()
|
|
|
|
function send(wc: WebContents, ev: TerminalEvent): void {
|
|
if (!wc.isDestroyed()) wc.send(IpcChannels.terminalOutput, ev)
|
|
}
|
|
|
|
export function runTerminal(wc: WebContents, id: string, argsRaw: string): TerminalRunResult {
|
|
if (!getSettings().customCommandEnabled) {
|
|
return {
|
|
ok: false,
|
|
error: 'Enable "Run custom commands" in Settings → Custom commands to use the terminal.'
|
|
}
|
|
}
|
|
ensureManagedYtdlp()
|
|
const ytdlp = getYtdlpPath()
|
|
if (!existsSync(ytdlp)) {
|
|
return { ok: false, error: 'yt-dlp.exe is missing and could not be restored.' }
|
|
}
|
|
if (active.has(id)) return { ok: false, error: 'A command is already running.' }
|
|
// L139: same exe-rewrite interlock as downloads — don't launch yt-dlp while a
|
|
// self-update is replacing the binary.
|
|
if (isYtdlpUpdating()) {
|
|
return { ok: false, error: 'yt-dlp is updating — try again in a moment.' }
|
|
}
|
|
|
|
// Always pin ffmpeg so post-processing works; the rest is whatever the user typed.
|
|
const args = ['--ffmpeg-location', getBinDir(), ...parseExtraArgs(argsRaw)]
|
|
let child: ChildProcess
|
|
try {
|
|
child = spawn(ytdlp, args, { windowsHide: true })
|
|
} catch (e) {
|
|
return { ok: false, error: (e as Error).message }
|
|
}
|
|
active.set(id, child)
|
|
acquireSpawn() // L139: live yt-dlp process — the updater must defer to it
|
|
|
|
// Buffer each stream and emit on newline boundaries (flush the remainder on close).
|
|
const lineBufs = {
|
|
stdout: createLineBuffer((line) => send(wc, { type: 'output', id, line, stream: 'stdout' })),
|
|
stderr: createLineBuffer((line) => send(wc, { type: 'output', id, line, stream: 'stderr' }))
|
|
}
|
|
child.stdout?.on('data', (c: Buffer) => lineBufs.stdout.push(c.toString()))
|
|
child.stderr?.on('data', (c: Buffer) => lineBufs.stderr.push(c.toString()))
|
|
|
|
let settled = false
|
|
child.on('error', (err) => {
|
|
if (settled) return
|
|
settled = true
|
|
active.delete(id)
|
|
releaseSpawn()
|
|
send(wc, { type: 'error', id, error: err.message })
|
|
})
|
|
child.on('close', (code) => {
|
|
if (settled) return
|
|
settled = true
|
|
active.delete(id)
|
|
releaseSpawn()
|
|
// Flush any trailing partial lines that never hit a newline.
|
|
lineBufs.stdout.flush()
|
|
lineBufs.stderr.flush()
|
|
send(wc, { type: 'done', id, code })
|
|
})
|
|
|
|
return { ok: true }
|
|
}
|
|
|
|
export function cancelTerminal(id: string): void {
|
|
const child = active.get(id)
|
|
if (!child) return
|
|
const pid = child.pid
|
|
if (pid != null) {
|
|
// Tree-kill via System32 taskkill by absolute path (same hardening as download.ts).
|
|
execFile(
|
|
getSystem32Path('taskkill.exe'),
|
|
['/pid', String(pid), '/T', '/F'],
|
|
{ windowsHide: true },
|
|
() => {}
|
|
)
|
|
} else {
|
|
child.kill()
|
|
}
|
|
}
|