Complete Phase C (custom commands & yt-dlp updater) and Phase D (library, UX & notifications)

Phase C had been implemented in the working tree but never committed:
named custom-command templates (CRUD + per-download override), command
preview, and the in-app yt-dlp self-updater.

Phase D adds: history search/kind-filter/multi-select/bulk-delete/
re-download; native OS notifications on completion/failure; a private/
incognito download mode that skips history; settings+template backup/
restore via JSON; and a persistent error log surfaced as a Diagnostics
report in Settings.

See ROADMAP.md for the full per-item breakdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 06:19:45 -04:00
parent 67133f13b5
commit a822a2bb52
24 changed files with 1678 additions and 129 deletions
+57
View File
@@ -0,0 +1,57 @@
import { dialog, type BrowserWindow } from 'electron'
import { readFileSync, writeFileSync } from 'fs'
import { getSettings, setSettings } from './settings'
import { listTemplates, replaceTemplates } from './templates'
import type {
BackupExportResult,
BackupImportResult,
Settings,
CommandTemplate
} from '@shared/ipc'
interface BackupFile {
version: 1
settings: Settings
templates: CommandTemplate[]
}
export async function exportBackup(win: BrowserWindow | undefined): Promise<BackupExportResult> {
const res = await dialog.showSaveDialog(win!, {
defaultPath: 'aerofetch-backup.json',
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (res.canceled || !res.filePath) return { ok: false }
const payload: BackupFile = { version: 1, settings: getSettings(), templates: listTemplates() }
try {
writeFileSync(res.filePath, JSON.stringify(payload, null, 2))
return { ok: true, path: res.filePath }
} catch (e) {
return { ok: false, error: (e as Error).message }
}
}
export async function importBackup(win: BrowserWindow | undefined): Promise<BackupImportResult> {
const res = await dialog.showOpenDialog(win!, {
properties: ['openFile'],
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (res.canceled || !res.filePaths[0]) return { ok: false }
let parsed: unknown
try {
parsed = JSON.parse(readFileSync(res.filePaths[0], 'utf8'))
} catch (e) {
return { ok: false, error: `Could not read backup file: ${(e as Error).message}` }
}
if (!parsed || typeof parsed !== 'object') {
return { ok: false, error: 'Not a valid AeroFetch backup file.' }
}
// setSettings/replaceTemplates validate and sanitize every field, so a
// hand-edited or partial backup file degrades gracefully rather than
// corrupting the store.
const file = parsed as Partial<BackupFile>
if (file.settings && typeof file.settings === 'object') setSettings(file.settings)
if (Array.isArray(file.templates)) replaceTemplates(file.templates)
return { ok: true }
}
+36 -1
View File
@@ -103,6 +103,35 @@ export interface AccessOptions {
// each at least 1MB so tiny files don't get split pointlessly.
export const ARIA2C_ARGS = 'aria2c:-x 16 -s 16 -k 1M'
/**
* Shell-like split of a raw "extra yt-dlp args" string (Phase C custom-command
* templates) into argv tokens. Supports single- and double-quoted spans so a
* flag value containing spaces (e.g. a --ppa recipe) can be typed as one
* token; no escape-character or nested-quote support beyond that.
*/
export function parseExtraArgs(raw: string): string[] {
const args: string[] = []
const re = /"([^"]*)"|'([^']*)'|(\S+)/g
let m: RegExpExecArray | null
while ((m = re.exec(raw)) !== null) {
args.push(m[1] ?? m[2] ?? m[3])
}
return args
}
// Wrap a single argv token for human-readable display only (Phase C command
// preview) — never used to build the real argv that gets spawned.
function quoteForDisplay(arg: string): string {
if (arg === '') return '""'
if (/[\s"]/.test(arg)) return `"${arg.replace(/"/g, '\\"')}"`
return arg
}
/** Render a binary + argv as a single copy-pasteable command line. */
export function formatCommandLine(exe: string, args: string[]): string {
return [exe, ...args].map(quoteForDisplay).join(' ')
}
function accessArgs(a: AccessOptions): string[] {
const args: string[] = []
if (a.proxy.trim()) args.push('--proxy', a.proxy.trim())
@@ -152,7 +181,8 @@ export function buildArgs(
outputTemplate: string,
o: DownloadOptions,
binDir: string,
access: AccessOptions
access: AccessOptions,
extraArgs: string[] = []
): string[] {
const args = [
'--newline',
@@ -194,6 +224,11 @@ export function buildArgs(
if (o.embedThumbnail && o.videoContainer !== 'webm') args.push('--embed-thumbnail')
}
// Custom-command extra args go last, immediately before the `--` URL
// terminator, so a user-supplied flag can override anything chosen above
// (yt-dlp takes the last occurrence of most options).
if (extraArgs.length > 0) args.push(...extraArgs)
// `--` terminates option parsing so the URL can never be read as a flag.
args.push('--', opts.url)
return args
+99 -27
View File
@@ -1,19 +1,23 @@
import { spawn, execFile, type ChildProcess } from 'child_process'
import { existsSync } from 'fs'
import { join } from 'path'
import { app, type WebContents } from 'electron'
import { app, BrowserWindow, Notification, type WebContents } from 'electron'
import { getYtdlpPath, getBinDir, getAria2cPath } from './binaries'
import { getSettings, getDownloadArchivePath } from './settings'
import { getCookiesFilePath } from './cookies'
import { listTemplates } from './templates'
import { assertHttpUrl } from './url'
import { buildArgs } from './buildArgs'
import { buildArgs, parseExtraArgs, formatCommandLine } from './buildArgs'
import { addErrorLog } from './errorlog'
import {
IpcChannels,
type StartDownloadOptions,
type StartDownloadResult,
type CommandPreviewResult,
type DownloadEvent,
type DownloadMeta,
type DownloadProgress
type DownloadProgress,
type Settings
} from '@shared/ipc'
interface ActiveDownload {
@@ -87,6 +91,33 @@ function send(wc: WebContents, ev: DownloadEvent): void {
if (!wc.isDestroyed()) wc.send(IpcChannels.downloadEvent, ev)
}
/** Native OS notification on completion/failure, gated by Settings.notifyOnComplete. */
function notify(wc: WebContents, title: string, body: string): void {
if (!getSettings().notifyOnComplete || !Notification.isSupported()) return
const n = new Notification({ title, body })
n.on('click', () => {
if (wc.isDestroyed()) return
const win = BrowserWindow.fromWebContents(wc)
if (win) {
if (win.isMinimized()) win.restore()
win.show()
win.focus()
}
})
n.show()
}
function logFailure(opts: StartDownloadOptions, title: string | undefined, error: string): void {
addErrorLog({
id: opts.id,
title,
url: opts.url,
kind: opts.kind,
error,
occurredAt: Date.now()
})
}
// --- Best-effort metadata probe (runs alongside the download) ---------------
function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
@@ -122,29 +153,21 @@ function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
})
}
// --- Public API -------------------------------------------------------------
// --- Argv construction (shared by startDownload and the command preview) ---
export function startDownload(
wc: WebContents,
opts: StartDownloadOptions
): StartDownloadResult {
const ytdlp = getYtdlpPath()
if (!existsSync(ytdlp)) {
return {
ok: false,
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.' }
// A per-download override (opts.extraArgs, even '') always wins; otherwise
// fall back to the persisted default template when custom-command mode is on.
function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): string[] {
if (opts.extraArgs !== undefined) return parseExtraArgs(opts.extraArgs)
if (settings.customCommandEnabled && settings.defaultTemplateId) {
const tpl = listTemplates().find((t) => t.id === settings.defaultTemplateId)
if (tpl) return parseExtraArgs(tpl.args)
}
return []
}
/** Resolve settings + per-download overrides into the full yt-dlp argv. */
export function buildCommand(opts: StartDownloadOptions): string[] {
const settings = getSettings()
const outDir = opts.outputDir?.trim() || settings.outputDir || app.getPath('downloads')
const template = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
@@ -168,12 +191,50 @@ export function startDownload(
restrictFilenames: settings.restrictFilenames,
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined
}
const extraArgs = resolveExtraArgs(opts, settings)
return buildArgs(opts, join(outDir, template), options, getBinDir(), access, extraArgs)
}
/** Build the exact command line for the current form state, without running it. */
export function previewCommand(opts: StartDownloadOptions): CommandPreviewResult {
try {
assertHttpUrl(opts.url)
} catch (e) {
return { ok: false, error: (e as Error).message }
}
try {
return { ok: true, command: formatCommandLine(getYtdlpPath(), buildCommand(opts)) }
} catch (e) {
return { ok: false, error: (e as Error).message }
}
}
// --- Public API -------------------------------------------------------------
export function startDownload(
wc: WebContents,
opts: StartDownloadOptions
): StartDownloadResult {
const ytdlp = getYtdlpPath()
if (!existsSync(ytdlp)) {
return {
ok: false,
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.' }
}
let child: ChildProcess
try {
child = spawn(ytdlp, buildArgs(opts, join(outDir, template), options, getBinDir(), access), {
windowsHide: true
})
child = spawn(ytdlp, buildCommand(opts), { windowsHide: true })
} catch (e) {
return { ok: false, error: (e as Error).message }
}
@@ -182,7 +243,11 @@ export function startDownload(
active.set(opts.id, rec)
// Fetch title/channel/duration in parallel so the card fills in quickly.
// Also kept locally so the completion notification/error log can show a
// real title instead of just the raw URL.
let resolvedTitle: string | undefined
probeMeta(ytdlp, opts.url).then((meta) => {
if (meta?.title) resolvedTitle = meta.title
if (meta && active.has(opts.id)) send(wc, { type: 'meta', id: opts.id, meta })
})
@@ -215,7 +280,11 @@ export function startDownload(
if (settled) return
settled = true
active.delete(opts.id)
if (!rec.canceled) send(wc, { type: 'error', id: opts.id, error: err.message })
if (!rec.canceled) {
send(wc, { type: 'error', id: opts.id, error: err.message })
logFailure(opts, resolvedTitle, err.message)
notify(wc, resolvedTitle ?? 'Download failed', err.message)
}
})
child.on('close', (code) => {
@@ -225,9 +294,12 @@ export function startDownload(
if (rec.canceled) return // renderer already showed 'canceled' optimistically
if (code === 0) {
send(wc, { type: 'done', id: opts.id, filePath })
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.')
} else {
const msg = cleanError(stderrTail) || `yt-dlp exited with code ${code}`
send(wc, { type: 'error', id: opts.id, error: msg })
logFailure(opts, resolvedTitle, msg)
notify(wc, resolvedTitle ?? 'Download failed', msg)
}
})
+41
View File
@@ -0,0 +1,41 @@
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { ErrorLogEntry } from '@shared/ipc'
// Plain JSON in userData, same shape as history.ts. Persisted so a failure
// report survives the queue item being cleared (Seal's "debug report").
const MAX_ENTRIES = 200
function errorLogFile(): string {
return join(app.getPath('userData'), 'errorlog.json')
}
export function listErrorLog(): ErrorLogEntry[] {
try {
if (!existsSync(errorLogFile())) return []
const data = JSON.parse(readFileSync(errorLogFile(), 'utf8'))
return Array.isArray(data) ? (data as ErrorLogEntry[]) : []
} catch {
return []
}
}
function save(entries: ErrorLogEntry[]): void {
try {
writeFileSync(errorLogFile(), JSON.stringify(entries.slice(0, MAX_ENTRIES), null, 2))
} catch {
/* best-effort; a read-only data dir just means no persisted error log */
}
}
export function addErrorLog(entry: ErrorLogEntry): ErrorLogEntry[] {
const entries = [entry, ...listErrorLog()]
save(entries)
return entries
}
export function clearErrorLog(): ErrorLogEntry[] {
save([])
return []
}
+7
View File
@@ -42,6 +42,13 @@ export function removeHistory(id: string): HistoryEntry[] {
return entries
}
export function removeManyHistory(ids: string[]): HistoryEntry[] {
const remove = new Set(ids)
const entries = listHistory().filter((e) => !remove.has(e.id))
save(entries)
return entries
}
export function clearHistory(): HistoryEntry[] {
save([])
return []
+50 -7
View File
@@ -5,16 +5,21 @@ import {
IpcChannels,
type StartDownloadOptions,
type Settings,
type HistoryEntry
type HistoryEntry,
type CommandTemplate,
type YtdlpUpdateChannel
} from '@shared/ipc'
import { getYtdlpVersion } from './ytdlp'
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
import { probeMedia } from './probe'
import { startDownload, cancelDownload } from './download'
import { startDownload, cancelDownload, previewCommand } from './download'
import { getSettings, setSettings } from './settings'
import { listHistory, addHistory, removeHistory, clearHistory } from './history'
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
import { listTemplates, saveTemplate, removeTemplate } from './templates'
import { setupPortableData } from './portable'
import { safeOpenPath, safeShowInFolder } from './reveal'
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
import { exportBackup, importBackup } from './backup'
// 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 —
@@ -86,9 +91,21 @@ function registerIpcHandlers(): void {
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) =>
startDownload(e.sender, opts)
)
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => {
const result = startDownload(e.sender, opts)
// Pre-spawn failures (missing yt-dlp.exe, bad URL, duplicate id) never reach
// download.ts's own close/error handlers, so log them here instead.
if (!result.ok) {
addErrorLog({
id: opts.id,
url: opts.url,
kind: opts.kind,
error: result.error ?? 'Unknown error',
occurredAt: Date.now()
})
}
return result
})
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
@@ -121,11 +138,37 @@ function registerIpcHandlers(): void {
ipcMain.handle(IpcChannels.historyList, () => listHistory())
ipcMain.handle(IpcChannels.historyAdd, (_e, entry: HistoryEntry) => addHistory(entry))
ipcMain.handle(IpcChannels.historyRemove, (_e, id: string) => removeHistory(id))
ipcMain.handle(IpcChannels.historyRemoveMany, (_e, ids: string[]) => removeManyHistory(ids))
ipcMain.handle(IpcChannels.historyClear, () => clearHistory())
ipcMain.handle(IpcChannels.cookiesLogin, (_e, url: string) => openCookieLoginWindow(url))
ipcMain.handle(IpcChannels.cookiesStatus, () => getCookiesStatus())
ipcMain.handle(IpcChannels.cookiesClear, () => clearCookies())
ipcMain.handle(IpcChannels.templatesList, () => listTemplates())
ipcMain.handle(IpcChannels.templatesSave, (_e, template: CommandTemplate) => saveTemplate(template))
ipcMain.handle(IpcChannels.templatesRemove, (_e, id: string) => removeTemplate(id))
ipcMain.handle(IpcChannels.commandPreview, (_e, opts: StartDownloadOptions) =>
previewCommand(opts)
)
ipcMain.handle(IpcChannels.ytdlpUpdate, (_e, channel: YtdlpUpdateChannel) => updateYtdlp(channel))
ipcMain.handle(IpcChannels.errorLogList, () => listErrorLog())
ipcMain.handle(IpcChannels.errorLogClear, () => clearErrorLog())
ipcMain.handle(IpcChannels.backupExport, (e) =>
exportBackup(BrowserWindow.fromWebContents(e.sender) ?? undefined)
)
ipcMain.handle(IpcChannels.backupImport, async (e) => {
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
const result = await importBackup(win)
// A restored backup may have changed the theme; keep the native window
// background in sync the same way settingsSet does.
if (result.ok) win?.setBackgroundColor(THEME_BACKGROUND[getSettings().theme])
return result
})
}
app.whenReady().then(() => {
+9 -1
View File
@@ -29,7 +29,10 @@ const DEFAULTS: Settings = {
cookieSource: 'none',
cookiesBrowser: 'chrome',
restrictFilenames: false,
downloadArchive: false
downloadArchive: false,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true
}
/** Fixed path for the --download-archive file; not user-configurable. */
@@ -115,8 +118,13 @@ export function setSettings(partial: Partial<Settings>): Settings {
case 'useAria2c':
case 'restrictFilenames':
case 'downloadArchive':
case 'customCommandEnabled':
case 'notifyOnComplete':
if (typeof value === 'boolean') s.set(key, value)
break
case 'defaultTemplateId':
if (value === null || typeof value === 'string') s.set('defaultTemplateId', value)
break
case 'cookieSource':
if (value === 'none' || value === 'browser' || value === 'login') s.set(key, value)
break
+58
View File
@@ -0,0 +1,58 @@
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { CommandTemplate } from '@shared/ipc'
// Plain JSON in userData, same shape as history.ts.
const MAX_TEMPLATES = 100
function templatesFile(): string {
return join(app.getPath('userData'), 'templates.json')
}
export function listTemplates(): CommandTemplate[] {
try {
if (!existsSync(templatesFile())) return []
const data = JSON.parse(readFileSync(templatesFile(), 'utf8'))
return Array.isArray(data) ? (data as CommandTemplate[]) : []
} catch {
return []
}
}
function save(templates: CommandTemplate[]): void {
try {
writeFileSync(templatesFile(), JSON.stringify(templates.slice(0, MAX_TEMPLATES), null, 2))
} catch {
/* best-effort; a read-only data dir just means no persisted templates */
}
}
function sanitize(t: CommandTemplate): CommandTemplate {
return {
id: String(t.id),
name: (t.name ?? '').trim() || 'Untitled command',
args: (t.args ?? '').trim()
}
}
/** Add a new template, or update an existing one (matched by id). */
export function saveTemplate(template: CommandTemplate): CommandTemplate[] {
const clean = sanitize(template)
const templates = [clean, ...listTemplates().filter((t) => t.id !== clean.id)]
save(templates)
return templates
}
export function removeTemplate(id: string): CommandTemplate[] {
const templates = listTemplates().filter((t) => t.id !== id)
save(templates)
return templates
}
/** Replace the entire template list (backup restore) rather than merge-by-id. */
export function replaceTemplates(templates: CommandTemplate[]): CommandTemplate[] {
const clean = templates.map(sanitize)
save(clean)
return clean
}
+36 -1
View File
@@ -1,7 +1,7 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries'
import type { YtdlpVersionResult } from '@shared/ipc'
import type { YtdlpVersionResult, YtdlpUpdateChannel, YtdlpUpdateResult } from '@shared/ipc'
/**
* Step-1 spike: spawn the bundled yt-dlp and read back `--version`.
@@ -30,3 +30,38 @@ export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
})
})
}
/**
* 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 function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> {
const ytdlpPath = getYtdlpPath()
if (!existsSync(ytdlpPath)) {
return Promise.resolve({
ok: false,
error: `yt-dlp.exe not found at ${ytdlpPath}\nDownload it into resources/bin/ (see the README there).`
})
}
return new Promise((resolve) => {
execFile(
ytdlpPath,
['--update-to', channel],
{ windowsHide: true, timeout: 60_000 },
(err, stdout, stderr) => {
if (err) {
const msg = (err as { killed?: boolean }).killed
? 'Timed out updating yt-dlp.'
: (stderr || err.message).trim()
resolve({ ok: false, error: msg })
return
}
resolve({ ok: true, output: stdout.trim() })
}
)
})
}