Add Seal-parity download options + playlist support (Phase A)
Phase A of the Seal feature-parity roadmap: configurable post-processing via a shared DownloadOptions model (src/shared/ipc.ts), editable as persisted defaults in Settings and overridable per-download in the download bar. - audio formats (mp3/m4a/opus/flac/wav/aac), video container (mp4/mkv/webm), preferred-codec sort (-S vcodec) - subtitles (download/embed/langs/auto), SponsorBlock (remove/mark + categories + force-keyframes-at-cuts), embed chapters/metadata/thumbnail, square-crop audio artwork - playlist downloads: probe via `-J --flat-playlist`, inline selection UI (checkbox list, select-all/none, live count); each entry enqueued as its own single-video download, reusing the existing queue/concurrency/history - reusable DownloadOptionsForm shared by Settings and the download bar so the defaults and per-download override never drift - ROADMAP.md tracking full Seal parity (phases A-E) Also includes in-tree security hardening that landed alongside: preload sandbox (CJS preload output), http(s)-only window-open + blocked renderer navigation, argv-injection guard for URLs (src/main/url.ts), extension-allowlisted file open/reveal (src/main/reveal.ts), yt-dlp version-check timeout, and a minimal window.electron presence marker. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+80
-15
@@ -4,6 +4,7 @@ import { join } from 'path'
|
||||
import { app, type WebContents } from 'electron'
|
||||
import { getYtdlpPath, getBinDir } from './binaries'
|
||||
import { getSettings } from './settings'
|
||||
import { assertHttpUrl } from './url'
|
||||
import {
|
||||
IpcChannels,
|
||||
BEST_FORMAT_ID,
|
||||
@@ -11,7 +12,8 @@ import {
|
||||
type StartDownloadResult,
|
||||
type DownloadEvent,
|
||||
type DownloadMeta,
|
||||
type DownloadProgress
|
||||
type DownloadProgress,
|
||||
type DownloadOptions
|
||||
} from '@shared/ipc'
|
||||
|
||||
interface ActiveDownload {
|
||||
@@ -75,7 +77,48 @@ const PROGRESS_TEMPLATE =
|
||||
'%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|' +
|
||||
'%(progress.speed)s|%(progress.eta)s'
|
||||
|
||||
function buildArgs(opts: StartDownloadOptions, outputTemplate: string): string[] {
|
||||
// Crop embedded audio artwork to a centred square. The double-quote/single-quote
|
||||
// dance is deliberate: yt-dlp shlex-splits the `--ppa` value, consuming the outer
|
||||
// double quotes and leaving ffmpeg single-quoted crop expressions whose commas are
|
||||
// protected from ffmpeg's filtergraph separator. Side = min(width, height).
|
||||
const CROP_SQUARE_PPA =
|
||||
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' +
|
||||
'crop="\'if(gt(ih,iw),iw,ih)\':\'if(gt(iw,ih),ih,iw)\'"'
|
||||
|
||||
/** Subtitle / SponsorBlock / chapter / metadata flags shared by both kinds. */
|
||||
function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string[] {
|
||||
const args: string[] = []
|
||||
|
||||
// Subtitles — video only; audio extraction has nowhere to embed them.
|
||||
if (opts.kind === 'video' && o.embedSubtitles) {
|
||||
args.push('--write-subs', '--embed-subs', '--sub-langs', o.subtitleLanguages || 'en')
|
||||
if (o.autoSubtitles) args.push('--write-auto-subs')
|
||||
// Normalise to srt first so embedding works across containers (mp4 → mov_text).
|
||||
args.push('--convert-subs', 'srt')
|
||||
}
|
||||
|
||||
// SponsorBlock.
|
||||
if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) {
|
||||
const cats = o.sponsorBlockCategories.join(',')
|
||||
if (o.sponsorBlockMode === 'remove') {
|
||||
// Force keyframes at the cut points so segment boundaries are frame-accurate.
|
||||
args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts')
|
||||
} else {
|
||||
args.push('--sponsorblock-mark', cats)
|
||||
}
|
||||
}
|
||||
|
||||
if (o.embedChapters) args.push('--embed-chapters')
|
||||
if (o.embedMetadata) args.push('--embed-metadata')
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function buildArgs(
|
||||
opts: StartDownloadOptions,
|
||||
outputTemplate: string,
|
||||
o: DownloadOptions
|
||||
): string[] {
|
||||
const args = [
|
||||
'--newline',
|
||||
'--no-color',
|
||||
@@ -95,21 +138,28 @@ function buildArgs(opts: StartDownloadOptions, outputTemplate: string): string[]
|
||||
'--no-simulate'
|
||||
]
|
||||
|
||||
args.push(...postProcessArgs(opts, o))
|
||||
|
||||
if (opts.kind === 'audio') {
|
||||
args.push(
|
||||
'-x',
|
||||
'--audio-format',
|
||||
'mp3',
|
||||
'--audio-quality',
|
||||
audioQuality(opts.quality),
|
||||
'--embed-thumbnail',
|
||||
'--embed-metadata'
|
||||
)
|
||||
args.push('-x', '--audio-format', o.audioFormat, '--audio-quality', audioQuality(opts.quality))
|
||||
if (o.embedThumbnail) {
|
||||
args.push('--embed-thumbnail')
|
||||
if (o.cropThumbnail) args.push('--ppa', CROP_SQUARE_PPA)
|
||||
}
|
||||
} else {
|
||||
args.push('-f', videoSelector(opts), '--merge-output-format', 'mp4')
|
||||
args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer)
|
||||
// Codec preference is a sort tiebreaker AFTER resolution/fps, so it nudges the
|
||||
// pick toward the chosen codec without overriding the requested quality.
|
||||
if (o.preferredVideoCodec !== 'any') {
|
||||
const token = o.preferredVideoCodec === 'av1' ? 'av01' : o.preferredVideoCodec
|
||||
args.push('-S', `res,fps,vcodec:${token}`)
|
||||
}
|
||||
// mp4/mkv carry a cover image; webm has no reliable way to embed one.
|
||||
if (o.embedThumbnail && o.videoContainer !== 'webm') args.push('--embed-thumbnail')
|
||||
}
|
||||
|
||||
args.push(opts.url)
|
||||
// `--` terminates option parsing so the URL can never be read as a flag.
|
||||
args.push('--', opts.url)
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -193,9 +243,10 @@ function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
|
||||
'uploader',
|
||||
'--print',
|
||||
'duration_string',
|
||||
'--',
|
||||
url
|
||||
],
|
||||
{ windowsHide: true, maxBuffer: 4 * 1024 * 1024 },
|
||||
{ windowsHide: true, maxBuffer: 4 * 1024 * 1024, timeout: 30_000 },
|
||||
(err, stdout) => {
|
||||
if (err) return resolve(null)
|
||||
const [title, uploader, duration] = stdout.split('\n').map((l) => l.trim())
|
||||
@@ -224,6 +275,12 @@ export function startDownload(
|
||||
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
|
||||
}
|
||||
}
|
||||
// Reject anything that isn't an http(s) URL before it reaches yt-dlp's argv.
|
||||
try {
|
||||
assertHttpUrl(opts.url)
|
||||
} catch (e) {
|
||||
return { ok: false, error: (e as Error).message }
|
||||
}
|
||||
if (active.has(opts.id)) {
|
||||
return { ok: false, error: 'A download with this id is already running.' }
|
||||
}
|
||||
@@ -231,10 +288,12 @@ export function startDownload(
|
||||
const settings = getSettings()
|
||||
const outDir = opts.outputDir?.trim() || settings.outputDir || app.getPath('downloads')
|
||||
const template = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
|
||||
// Per-download override wins; otherwise use the persisted defaults.
|
||||
const options = opts.options ?? settings.downloadOptions
|
||||
|
||||
let child: ChildProcess
|
||||
try {
|
||||
child = spawn(ytdlp, buildArgs(opts, join(outDir, template)), { windowsHide: true })
|
||||
child = spawn(ytdlp, buildArgs(opts, join(outDir, template), options), { windowsHide: true })
|
||||
} catch (e) {
|
||||
return { ok: false, error: (e as Error).message }
|
||||
}
|
||||
@@ -250,6 +309,8 @@ export function startDownload(
|
||||
let stdoutBuf = ''
|
||||
let stderrTail = ''
|
||||
let filePath: string | undefined
|
||||
// 'error' and 'close' can both fire for one process; only act on the first.
|
||||
let settled = false
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
stdoutBuf += chunk.toString()
|
||||
@@ -271,11 +332,15 @@ export function startDownload(
|
||||
})
|
||||
|
||||
child.on('error', (err) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
active.delete(opts.id)
|
||||
if (!rec.canceled) send(wc, { type: 'error', id: opts.id, error: err.message })
|
||||
})
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
active.delete(opts.id)
|
||||
if (rec.canceled) return // renderer already showed 'canceled' optimistically
|
||||
if (code === 0) {
|
||||
|
||||
+20
-8
@@ -13,6 +13,7 @@ import { startDownload, cancelDownload } from './download'
|
||||
import { getSettings, setSettings } from './settings'
|
||||
import { listHistory, addHistory, removeHistory, clearHistory } from './history'
|
||||
import { setupPortableData } from './portable'
|
||||
import { safeOpenPath, safeShowInFolder } from './reveal'
|
||||
|
||||
// Force software compositing (GPU acceleration off). On this dev machine's old GeForce
|
||||
// GT 625 — and plausibly on the old / locked-down public PCs this app targets —
|
||||
@@ -45,8 +46,8 @@ function createWindow(): void {
|
||||
backgroundColor: THEME_BACKGROUND[getSettings().theme],
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.mjs'),
|
||||
sandbox: false,
|
||||
preload: join(__dirname, '../preload/index.cjs'),
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
@@ -56,12 +57,22 @@ function createWindow(): void {
|
||||
mainWindow.show()
|
||||
})
|
||||
|
||||
// Open external links in the OS browser, never in-app.
|
||||
// Open external links in the OS browser, never in-app — and only http(s), so a
|
||||
// file:// or custom-protocol URL can't be used to launch a local handler.
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
try {
|
||||
const { protocol } = new URL(details.url)
|
||||
if (protocol === 'http:' || protocol === 'https:') shell.openExternal(details.url)
|
||||
} catch {
|
||||
/* unparseable URL — ignore */
|
||||
}
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// The app shell is local and self-contained — never let the renderer navigate
|
||||
// away from it (defence in depth; HMR uses websockets, not navigation).
|
||||
mainWindow.webContents.on('will-navigate', (e) => e.preventDefault())
|
||||
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
@@ -89,8 +100,8 @@ function registerIpcHandlers(): void {
|
||||
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannels.openPath, (_e, p: string) => shell.openPath(p))
|
||||
ipcMain.handle(IpcChannels.showInFolder, (_e, p: string) => shell.showItemInFolder(p))
|
||||
ipcMain.handle(IpcChannels.openPath, (_e, p: string) => safeOpenPath(p))
|
||||
ipcMain.handle(IpcChannels.showInFolder, (_e, p: string) => safeShowInFolder(p))
|
||||
|
||||
ipcMain.handle(IpcChannels.clipboardRead, () => clipboard.readText())
|
||||
|
||||
@@ -98,9 +109,10 @@ function registerIpcHandlers(): void {
|
||||
ipcMain.handle(IpcChannels.settingsSet, (e, partial: Partial<Settings>) => {
|
||||
const result = setSettings(partial)
|
||||
// Keep the window's native background in sync with the theme so a compositor
|
||||
// repaint never flashes a mismatched color behind an overlay.
|
||||
// repaint never flashes a mismatched color behind an overlay. Use the
|
||||
// validated result, not the raw partial (which may hold a bogus value).
|
||||
if (partial.theme) {
|
||||
BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(THEME_BACKGROUND[partial.theme])
|
||||
BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(THEME_BACKGROUND[result.theme])
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
+95
-6
@@ -2,7 +2,15 @@ import { execFile } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { getYtdlpPath } from './binaries'
|
||||
import { fmtBytes } from './download'
|
||||
import { BEST_FORMAT_ID, type ProbeResult, type MediaInfo, type FormatOption } from '@shared/ipc'
|
||||
import { assertHttpUrl } from './url'
|
||||
import {
|
||||
BEST_FORMAT_ID,
|
||||
type ProbeResult,
|
||||
type MediaInfo,
|
||||
type FormatOption,
|
||||
type PlaylistInfo,
|
||||
type PlaylistEntry
|
||||
} from '@shared/ipc'
|
||||
|
||||
// The slice of `yt-dlp -J` output we actually read.
|
||||
interface RawFormat {
|
||||
@@ -17,13 +25,25 @@ interface RawFormat {
|
||||
filesize_approx?: number
|
||||
}
|
||||
|
||||
interface RawEntry {
|
||||
id?: string
|
||||
title?: string
|
||||
url?: string
|
||||
webpage_url?: string
|
||||
duration?: number
|
||||
uploader?: string
|
||||
channel?: string
|
||||
}
|
||||
|
||||
interface RawInfo {
|
||||
_type?: string
|
||||
title?: string
|
||||
uploader?: string
|
||||
channel?: string
|
||||
duration_string?: string
|
||||
thumbnail?: string
|
||||
formats?: RawFormat[]
|
||||
entries?: RawEntry[]
|
||||
}
|
||||
|
||||
/** Quality proxy for picking the best format at a given height. */
|
||||
@@ -71,6 +91,51 @@ function buildInfo(data: RawInfo): MediaInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/** Seconds → 'M:SS' or 'H:MM:SS'. Flat playlist entries give duration as a number. */
|
||||
function fmtDuration(sec?: number): string | undefined {
|
||||
if (sec == null || !Number.isFinite(sec)) return undefined
|
||||
const s = Math.max(0, Math.round(sec))
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
const r = s % 60
|
||||
const mm = h ? String(m).padStart(2, '0') : String(m)
|
||||
return `${h ? `${h}:` : ''}${mm}:${String(r).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the URL we'll enqueue for a playlist entry. Prefer an explicit http(s)
|
||||
* URL; fall back to a YouTube watch URL built from the id (the common case where
|
||||
* flat entries carry only an id). Returns null when nothing usable is present.
|
||||
*/
|
||||
function entryUrl(e: RawEntry): string | null {
|
||||
const cand = e.url || e.webpage_url
|
||||
if (cand && /^https?:\/\//i.test(cand)) return cand
|
||||
if (e.id) return `https://www.youtube.com/watch?v=${e.id}`
|
||||
return null
|
||||
}
|
||||
|
||||
function buildPlaylist(data: RawInfo): PlaylistInfo {
|
||||
const entries: PlaylistEntry[] = []
|
||||
;(data.entries ?? []).forEach((e, i) => {
|
||||
const url = entryUrl(e)
|
||||
if (!url) return // skip entries we can't turn into a downloadable URL
|
||||
entries.push({
|
||||
index: i + 1,
|
||||
id: e.id || String(i + 1),
|
||||
title: e.title || `Item ${i + 1}`,
|
||||
url,
|
||||
durationLabel: fmtDuration(e.duration),
|
||||
uploader: e.uploader || e.channel || undefined
|
||||
})
|
||||
})
|
||||
return {
|
||||
title: data.title || 'Playlist',
|
||||
uploader: data.uploader || data.channel || undefined,
|
||||
count: entries.length,
|
||||
entries
|
||||
}
|
||||
}
|
||||
|
||||
function cleanError(stderr: string): string {
|
||||
const lines = stderr
|
||||
.split('\n')
|
||||
@@ -80,7 +145,11 @@ function cleanError(stderr: string): string {
|
||||
return (errLine ?? lines[lines.length - 1] ?? '').replace(/^error:\s*/i, '').trim()
|
||||
}
|
||||
|
||||
/** Probe a URL with `yt-dlp -J` and return metadata + available video formats. */
|
||||
/**
|
||||
* Probe a URL with `yt-dlp -J --flat-playlist`. Resolves to a single video (with
|
||||
* its format list) or, when the URL is a playlist, the flat list of its entries.
|
||||
* `--flat-playlist` is a no-op for a lone video, so one call covers both cases.
|
||||
*/
|
||||
export function probeMedia(url: string): Promise<ProbeResult> {
|
||||
const ytdlp = getYtdlpPath()
|
||||
if (!existsSync(ytdlp)) {
|
||||
@@ -89,19 +158,39 @@ export function probeMedia(url: string): Promise<ProbeResult> {
|
||||
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
|
||||
})
|
||||
}
|
||||
try {
|
||||
assertHttpUrl(url)
|
||||
} catch (e) {
|
||||
return Promise.resolve({ ok: false, error: (e as Error).message })
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
ytdlp,
|
||||
['-J', '--no-playlist', '--no-warnings', url],
|
||||
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024 },
|
||||
// `--` terminates option parsing so the URL can never be read as a flag.
|
||||
['-J', '--flat-playlist', '--no-warnings', '--', url],
|
||||
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024, timeout: 60_000 },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
resolve({ ok: false, error: cleanError(stderr) || err.message })
|
||||
// execFile sets `killed` when it terminated the process on timeout.
|
||||
const msg = (err as { killed?: boolean }).killed
|
||||
? 'Timed out fetching video info. Check the link or your connection.'
|
||||
: cleanError(stderr) || err.message
|
||||
resolve({ ok: false, error: msg })
|
||||
return
|
||||
}
|
||||
try {
|
||||
resolve({ ok: true, info: buildInfo(JSON.parse(stdout) as RawInfo) })
|
||||
const data = JSON.parse(stdout) as RawInfo
|
||||
if (data._type === 'playlist' || Array.isArray(data.entries)) {
|
||||
const playlist = buildPlaylist(data)
|
||||
if (playlist.count === 0) {
|
||||
resolve({ ok: false, error: 'This playlist has no downloadable entries.' })
|
||||
return
|
||||
}
|
||||
resolve({ ok: true, kind: 'playlist', playlist })
|
||||
} else {
|
||||
resolve({ ok: true, kind: 'video', info: buildInfo(data) })
|
||||
}
|
||||
} catch {
|
||||
resolve({ ok: false, error: 'Could not parse video info from yt-dlp.' })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { shell } from 'electron'
|
||||
import { existsSync, statSync } from 'fs'
|
||||
import { extname, isAbsolute } from 'path'
|
||||
|
||||
/**
|
||||
* Open/reveal helpers used by the shell:* IPC handlers.
|
||||
*
|
||||
* The renderer supplies the path (it originates from yt-dlp's after-move print),
|
||||
* but the IPC boundary must not trust it blindly: a compromised renderer could
|
||||
* otherwise call openPath() on an arbitrary executable and have the OS run it.
|
||||
* So openPath is confined to existing files with a known media extension —
|
||||
* never .exe/.bat/.ps1/etc.
|
||||
*/
|
||||
const OPENABLE_EXTENSIONS = new Set([
|
||||
// video
|
||||
'.mp4', '.mkv', '.webm', '.mov', '.avi', '.flv', '.ts', '.m4v', '.3gp', '.ogv',
|
||||
// audio
|
||||
'.mp3', '.m4a', '.opus', '.ogg', '.oga', '.aac', '.flac', '.wav', '.wma',
|
||||
// subtitle sidecars (plain text — safe to open)
|
||||
'.vtt', '.srt'
|
||||
])
|
||||
|
||||
/** Open a downloaded media file with its default app. Returns '' on success,
|
||||
* or an error string (matching shell.openPath's contract). */
|
||||
export async function safeOpenPath(p: unknown): Promise<string> {
|
||||
if (typeof p !== 'string' || !isAbsolute(p)) return 'Invalid path.'
|
||||
if (!OPENABLE_EXTENSIONS.has(extname(p).toLowerCase())) {
|
||||
return 'Refusing to open this file type.'
|
||||
}
|
||||
try {
|
||||
if (!statSync(p).isFile()) return 'Not a file.'
|
||||
} catch {
|
||||
return 'File not found.'
|
||||
}
|
||||
return shell.openPath(p)
|
||||
}
|
||||
|
||||
/** Reveal a path in the OS file manager. No-op for missing/invalid paths. */
|
||||
export function safeShowInFolder(p: unknown): void {
|
||||
if (typeof p !== 'string' || !isAbsolute(p) || !existsSync(p)) return
|
||||
shell.showItemInFolder(p)
|
||||
}
|
||||
+84
-5
@@ -1,6 +1,15 @@
|
||||
import { app } from 'electron'
|
||||
import Store from 'electron-store'
|
||||
import type { Settings } from '@shared/ipc'
|
||||
import {
|
||||
AUDIO_FORMATS,
|
||||
VIDEO_CONTAINERS,
|
||||
VIDEO_CODECS,
|
||||
SPONSORBLOCK_CATEGORIES,
|
||||
DEFAULT_DOWNLOAD_OPTIONS,
|
||||
type Settings,
|
||||
type DownloadOptions,
|
||||
type SponsorBlockCategory
|
||||
} from '@shared/ipc'
|
||||
|
||||
const DEFAULTS: Settings = {
|
||||
outputDir: '', // resolved to the OS Downloads folder on first read
|
||||
@@ -10,7 +19,45 @@ const DEFAULTS: Settings = {
|
||||
maxConcurrent: 2,
|
||||
filenameTemplate: '%(title)s.%(ext)s',
|
||||
theme: 'light',
|
||||
clipboardWatch: true
|
||||
clipboardWatch: true,
|
||||
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS
|
||||
}
|
||||
|
||||
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the
|
||||
// defaults for any missing/invalid field. Used both to migrate older settings
|
||||
// files (which predate downloadOptions) and to validate renderer writes.
|
||||
function sanitizeOptions(input: unknown): DownloadOptions {
|
||||
const o = (input && typeof input === 'object' ? input : {}) as Partial<DownloadOptions>
|
||||
const d = DEFAULT_DOWNLOAD_OPTIONS
|
||||
const bool = (v: unknown, fallback: boolean): boolean =>
|
||||
typeof v === 'boolean' ? v : fallback
|
||||
const cats = Array.isArray(o.sponsorBlockCategories)
|
||||
? (o.sponsorBlockCategories.filter((c) =>
|
||||
(SPONSORBLOCK_CATEGORIES as readonly string[]).includes(c)
|
||||
) as SponsorBlockCategory[])
|
||||
: d.sponsorBlockCategories
|
||||
return {
|
||||
audioFormat: AUDIO_FORMATS.includes(o.audioFormat as never) ? o.audioFormat! : d.audioFormat,
|
||||
videoContainer: VIDEO_CONTAINERS.includes(o.videoContainer as never)
|
||||
? o.videoContainer!
|
||||
: d.videoContainer,
|
||||
preferredVideoCodec: VIDEO_CODECS.includes(o.preferredVideoCodec as never)
|
||||
? o.preferredVideoCodec!
|
||||
: d.preferredVideoCodec,
|
||||
embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles),
|
||||
subtitleLanguages:
|
||||
typeof o.subtitleLanguages === 'string' && o.subtitleLanguages.trim()
|
||||
? o.subtitleLanguages.trim()
|
||||
: d.subtitleLanguages,
|
||||
autoSubtitles: bool(o.autoSubtitles, d.autoSubtitles),
|
||||
sponsorBlock: bool(o.sponsorBlock, d.sponsorBlock),
|
||||
sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove',
|
||||
sponsorBlockCategories: cats,
|
||||
embedChapters: bool(o.embedChapters, d.embedChapters),
|
||||
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
|
||||
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
|
||||
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail)
|
||||
}
|
||||
}
|
||||
|
||||
// Constructed lazily — electron-store needs app paths, which exist only after
|
||||
@@ -25,14 +72,46 @@ export function getSettings(): Settings {
|
||||
const s = getStore()
|
||||
// Fill in the real Downloads path the first time, and persist it.
|
||||
if (!s.get('outputDir')) s.set('outputDir', app.getPath('downloads'))
|
||||
// Migrate settings files that predate downloadOptions (or hold a partial one).
|
||||
s.set('downloadOptions', sanitizeOptions(s.get('downloadOptions')))
|
||||
return s.store
|
||||
}
|
||||
|
||||
// Validate each key before persisting — the renderer is the only caller today,
|
||||
// but settings flow into process spawning (maxConcurrent) and the native window
|
||||
// background (theme), so an out-of-range or malformed value shouldn't get stored.
|
||||
export function setSettings(partial: Partial<Settings>): Settings {
|
||||
const s = getStore()
|
||||
;(Object.keys(partial) as (keyof Settings)[]).forEach((key) => {
|
||||
for (const key of Object.keys(partial) as (keyof Settings)[]) {
|
||||
const value = partial[key]
|
||||
if (value !== undefined) s.set(key, value)
|
||||
})
|
||||
if (value === undefined) continue
|
||||
switch (key) {
|
||||
case 'theme':
|
||||
if (value === 'light' || value === 'dark') s.set('theme', value)
|
||||
break
|
||||
case 'defaultKind':
|
||||
if (value === 'video' || value === 'audio') s.set('defaultKind', value)
|
||||
break
|
||||
case 'maxConcurrent': {
|
||||
const n = Number(value)
|
||||
if (Number.isFinite(n)) s.set('maxConcurrent', Math.min(5, Math.max(1, Math.round(n))))
|
||||
break
|
||||
}
|
||||
case 'clipboardWatch':
|
||||
if (typeof value === 'boolean') s.set('clipboardWatch', value)
|
||||
break
|
||||
case 'downloadOptions':
|
||||
// Always store a fully-validated object; the renderer sends the whole
|
||||
// group (it merges field changes locally before calling setSettings).
|
||||
s.set('downloadOptions', sanitizeOptions(value))
|
||||
break
|
||||
case 'outputDir':
|
||||
case 'defaultVideoQuality':
|
||||
case 'defaultAudioQuality':
|
||||
case 'filenameTemplate':
|
||||
if (typeof value === 'string') s.set(key, value)
|
||||
break
|
||||
}
|
||||
}
|
||||
return getSettings()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Validate that a string is an http(s) URL and return it trimmed.
|
||||
*
|
||||
* This is the front-line guard against yt-dlp *argument injection*: the URL is
|
||||
* passed to yt-dlp as a positional argv element, so a value beginning with `-`
|
||||
* (e.g. `--config-locations=…`, `--load-info-json=…`, `--exec`) would be parsed
|
||||
* as an OPTION rather than a URL — which can lead to arbitrary command
|
||||
* execution. A string that `new URL()` accepts with an http/https protocol
|
||||
* necessarily begins with its scheme, so it can never be read as an option.
|
||||
* (Call sites also pass `--` before the positional URL as defence in depth.)
|
||||
*
|
||||
* Throws a user-friendly Error on anything that isn't an http(s) URL.
|
||||
*/
|
||||
export function assertHttpUrl(raw: string): string {
|
||||
const trimmed = (raw ?? '').trim()
|
||||
let u: URL
|
||||
try {
|
||||
u = new URL(trimmed)
|
||||
} catch {
|
||||
throw new Error('That doesn’t look like a valid URL.')
|
||||
}
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||
throw new Error('Only http and https links are supported.')
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
+5
-2
@@ -18,9 +18,12 @@ export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(ytdlpPath, ['--version'], { windowsHide: true }, (err, stdout, stderr) => {
|
||||
execFile(ytdlpPath, ['--version'], { windowsHide: true, timeout: 15_000 }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
resolve({ ok: false, error: (stderr || err.message).trim() })
|
||||
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() })
|
||||
|
||||
Reference in New Issue
Block a user