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:
2026-06-22 19:32:59 -04:00
parent d4b9a0eefa
commit bb5dd6c438
21 changed files with 1101 additions and 91 deletions
+20 -8
View File
@@ -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
})