8 Commits

Author SHA1 Message Date
debont80 08b9ed9e7a chore: bump version to 0.4.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:01:51 -04:00
debont80 5e3512f366 feat: show real media thumbnails with theme-aware fallback
Add MediaThumb component and thumb.ts helper that resolve a preview image
from a probed thumbnail, a known videoId, or a derivable YouTube id
(mqdefault.jpg), falling back to a kind icon on a neutral tint when no
image is available or it fails to load. Wire it into the queue, history,
and library rows.

Also fix theme resolution: introduce useResolvedDark() as the single
source of truth for light/dark so 'system' is resolved against the live
OS signal, fixing thumbnails/colors staying light under Auto + dark OS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:01:38 -04:00
debont80 e8ab8b9c73 Merge feat/documents-folders-and-ui: Documents folders, collapsible sidebar, theme switch
# Conflicts:
#	src/main/download.ts
#	src/renderer/src/components/DownloadBar.tsx
2026-06-24 11:26:52 -04:00
debont80 76098c6928 Merge pull request 'security: harden command exec, IPC, deep-link, cookies, and persistence' (#2) from security/audit-hardening into main
Reviewed-on: #2
2026-06-24 08:06:55 -04:00
debont80 3536626a8a security: harden command exec, IPC, deep-link, cookies, and persistence
Seven-tier security audit of the main process, each finding fixed with a
regression test. Typecheck (node + web) clean; unit tests 106 -> 140.

- Tier 1 (command exec/argv): allowlist the yt-dlp --update-to channel
  (blocks arbitrary-binary-install RCE); gate per-download extraArgs behind
  the customCommandEnabled consent flag in main (blocks --exec RCE); resolve
  taskkill/schtasks by absolute System32 path; validate per-download
  outputDir; normalize the URL in assertHttpUrl and use it at every spawn.
- Tier 2 (input validation): fix isSafeFilenameTemplate drive-relative
  ('C:foo') and Windows dotted-'..' traversal bypasses; percent-encode the
  untrusted id in entryUrl; catch reserved device names with extensions in
  sanitizeDirSegment.
- Tier 3 (fs/backup): drop malformed template rows in importBackup;
  normalize deep-link URLs via assertHttpUrl.
- Tier 4 (cookies): confine the sign-in window's navigations/popups to web
  URLs (recursively) and deny all web permissions on its session.
- Tier 5 (deep-link/argv): bound the .url file read to 64 KB; match the
  aerofetch:// scheme case-insensitively.
- Tier 6 (Electron window): deny camera/mic/geolocation/USB/HID/serial/
  Bluetooth permissions on the app window.
- Tier 7 (network/persistence): restrict the watched-source RSS fetch to
  youtube.com feed URLs (SSRF guard); complete isValidSource validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 08:04:19 -04:00
wayne 2718624828 Show app version in sidebar, make the sidebar collapsible, and add a Light/Dark/Auto theme switch
- Expose the AeroFetch version over IPC (app:version → app.getVersion); the
  sidebar brand now shows "v<version>" beneath the name.
- Sidebar can collapse to a 60px icon-only rail via a toggle button; nav items
  and the theme control fall back to icon + tooltip when collapsed. The state is
  persisted in localStorage so it survives restarts. Hint gains left/right
  placements for the collapsed-rail tooltips.
- Replace the binary dark-mode Switch with an explicit 3-way Light / Dark / Auto
  segmented control (Auto follows the OS). Collapsed, it becomes a single button
  that cycles the three modes.
- Bump version to 0.3.2.

Verified in the browser UI preview: version label, collapse/expand, dark mode,
and the theme switch all render correctly with no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:43:44 -04:00
wayne 37687f9870 Separate per-kind video/audio folders, defaulting to Documents\Video and Documents\Audio
Existing installs had outputDir persisted to the Downloads folder (auto-filled by
earlier versions), so the v0.3.0 per-kind routing never triggered — downloads kept
going to Downloads. Replace the single outputDir setting with independent videoDir
and audioDir settings:

- Settings model: drop Settings.outputDir; add videoDir + audioDir (both blank by
  default). A blank value routes that kind into Documents\Video / Documents\Audio;
  a chosen folder overrides it. The stale outputDir key in old settings files is
  simply ignored, so existing users now get the Documents defaults.
- buildCommand routes by kind: per-download override → per-kind folder → default.
- The renderer no longer sends a global outputDir, so main always routes by kind.
- Settings: the single "Download folder" field becomes separate "Video folder" and
  "Audio folder" pickers, each with a Browse + Reset and a Documents\… placeholder.
  Store gains chooseDir(target) / clearDir(target).
- Onboarding: replace the folder picker with a short note about the two default
  folders (changeable in Settings).
- Bump version to 0.3.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:11:15 -04:00
wayne a2763f10b4 Route downloads into Documents\Video / Documents\Audio and simplify the home page
- Create Documents\Video and Documents\Audio on startup (ensureMediaDirs), and
  route each download into them by kind when no explicit output folder is set.
  An empty outputDir now means "sort by type"; a chosen folder still overrides
  for both kinds.
- Settings/Onboarding "Download folder" gains a placeholder + hint describing
  the blank = route-by-type behaviour.
- Trim the home download bar to the essentials: URL box, Search and Paste
  buttons, format/quality, and the Download button. Removed the private-mode
  toggle, per-download Options panel, custom-command panel, command preview, and
  the saving-to-folder line.
- Bump version to 0.3.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:17:16 -04:00
39 changed files with 1116 additions and 487 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aerofetch",
"version": "0.2.0",
"version": "0.4.0",
"description": "A yt-dlp frontend for Windows",
"main": "./out/main/index.js",
"author": "AeroFetch",
+6 -1
View File
@@ -2,6 +2,7 @@ import { dialog, type BrowserWindow } from 'electron'
import { readFileSync, writeFileSync } from 'fs'
import { getSettings, setSettings } from './settings'
import { listTemplates, replaceTemplates } from './templates'
import { isTemplateLike } from './validation'
import type {
BackupExportResult,
BackupImportResult,
@@ -55,8 +56,12 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
file.settings && typeof file.settings === 'object'
? (file.settings as Partial<Settings>)
: undefined
// Drop non-object / id-less entries up front (audit T3) so both the consent
// check below and replaceTemplates see only well-shaped rows. Without this a
// backup with a null/garbage templates entry throws in sanitize() instead of
// degrading gracefully as this function promises.
const incomingTemplates = Array.isArray(file.templates)
? (file.templates as CommandTemplate[])
? (file.templates as CommandTemplate[]).filter(isTemplateLike)
: []
// A custom-command template's `args` are extra yt-dlp flags that get spawned on
+12
View File
@@ -35,3 +35,15 @@ export function getFfprobePath(): string {
export function getAria2cPath(): string {
return join(getBinDir(), 'aria2c.exe')
}
/**
* Absolute path to a Windows system executable (e.g. taskkill.exe, schtasks.exe).
*
* SECURITY (audit F3): system tools are resolved by full path under System32
* rather than by bare name, so a same-named binary planted in the current
* working directory or earlier on PATH can't be invoked in their place — a real
* risk for the portable build, which runs from user-writable locations.
*/
export function getSystem32Path(exe: string): string {
return join(process.env.SystemRoot || 'C:\\Windows', 'System32', exe)
}
+38 -2
View File
@@ -14,7 +14,8 @@ import {
type StartDownloadOptions,
type DownloadOptions,
type CollectionContext,
type CookieBrowser
type CookieBrowser,
type CommandTemplate
} from '@shared/ipc'
// --- Quality → yt-dlp selector mapping --------------------------------------
@@ -127,6 +128,39 @@ export function parseExtraArgs(raw: string): string[] {
return args
}
/**
* Resolve the extra yt-dlp args for a download, enforcing the custom-command
* consent gate (audit F2).
*
* Extra args are powerful enough to run arbitrary code (e.g. `--exec`), so they
* are honoured ONLY when custom commands are explicitly enabled in settings —
* the same persisted flag the Settings UI and backup-import treat as consent. A
* per-download override wins over the persisted default template, but NEITHER is
* applied while the gate is off. This keeps a compromised renderer from smuggling
* code-exec flags through a lone `startDownload({ extraArgs })` call: it would
* first have to flip the visible `customCommandEnabled` setting, leaving a trace
* (the same defence-in-depth posture as the main-side maxConcurrent cap).
*
* - customCommandEnabled off → [] (always)
* - perDownloadExtraArgs defined (even '') → those args
* - else a matching defaultTemplateId → that template's args
* - else → []
*/
export function selectExtraArgs(params: {
customCommandEnabled: boolean
perDownloadExtraArgs: string | undefined
defaultTemplateId: string | null
templates: Pick<CommandTemplate, 'id' | 'args'>[]
}): string[] {
if (!params.customCommandEnabled) return []
if (params.perDownloadExtraArgs !== undefined) return parseExtraArgs(params.perDownloadExtraArgs)
if (params.defaultTemplateId) {
const tpl = params.templates.find((t) => t.id === params.defaultTemplateId)
if (tpl) return parseExtraArgs(tpl.args)
}
return []
}
// 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 {
@@ -163,7 +197,9 @@ export function sanitizeDirSegment(name: string): string {
let s = Array.from(name ?? '', (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('')
s = s.replace(/[<>:"/\\|?*]/g, ' ')
s = s.replace(/\s+/g, ' ').trim().replace(/[. ]+$/, '').replace(/^[. ]+/, '')
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(s)) s = `_${s}`
// Reserved device names are reserved even WITH an extension ('CON.txt' is still
// the CON device), so match an optional trailing '.<ext>' too (audit T3).
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(s)) s = `_${s}`
s = s.slice(0, 80).trim()
return s || 'Untitled'
}
+56 -21
View File
@@ -1,4 +1,4 @@
import { app, session, BrowserWindow, type Cookie } from 'electron'
import { app, session, BrowserWindow, type Cookie, type WebContents } from 'electron'
import { existsSync, statSync, unlinkSync, writeFileSync } from 'fs'
import { join } from 'path'
import { assertHttpUrl } from './url'
@@ -11,6 +11,55 @@ import type { CookiesStatus, CookiesLoginResult } from '@shared/ipc'
*/
const PARTITION = 'persist:aerofetch-login'
/**
* The sign-in window renders untrusted remote content, so every navigation and
* popup is confined to web URLs — http(s), plus about:blank. This stops a
* logged-in (or malicious) page from steering the window to a file:// URL, to
* the app's own aerofetch:// protocol handler, or to any other external URI
* scheme used as a pivot. (audit T4)
*/
export function isAllowedLoginUrl(target: string): boolean {
try {
const { protocol } = new URL(target)
return protocol === 'http:' || protocol === 'https:' || protocol === 'about:'
} catch {
return false
}
}
/**
* Apply the web-only confinement to a sign-in webContents: block top-frame
* navigations and server redirects to non-web URLs, restrict popups to web URLs
* (reusing the same secure, partitioned webPreferences), and recurse into any
* popup the page opens so a nested window can't escape the policy either. The
* window-open handler alone only governs NEW windows, not navigations of an
* existing one — both vectors are covered here. (audit T4)
*/
function hardenLoginWebContents(wc: WebContents): void {
wc.setWindowOpenHandler((details) => {
if (!isAllowedLoginUrl(details.url)) return { action: 'deny' }
return {
action: 'allow',
overrideBrowserWindowOptions: {
autoHideMenuBar: true,
webPreferences: {
partition: PARTITION,
sandbox: true,
contextIsolation: true,
nodeIntegration: false
}
}
}
})
wc.on('will-navigate', (e, navUrl) => {
if (!isAllowedLoginUrl(navUrl)) e.preventDefault()
})
wc.on('will-redirect', (e, navUrl) => {
if (!isAllowedLoginUrl(navUrl)) e.preventDefault()
})
wc.on('did-create-window', (child) => hardenLoginWebContents(child.webContents))
}
export function getCookiesFilePath(): string {
return join(app.getPath('userData'), 'cookies.txt')
}
@@ -114,26 +163,12 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
}
loginWindow = win
// Some sites log in via an OAuth/SSO popup. Let those open as real windows
// sharing the same partition, rather than silently swallowing the click.
// Restrict popups to http(s) only — the same defence the main window applies
// (index.ts) — so a logged-in page can't open a file:// popup to exfil cookies
// to disk or use a custom-protocol popup as a pivot.
win.webContents.setWindowOpenHandler((details) => {
try {
const { protocol } = new URL(details.url)
if (protocol !== 'http:' && protocol !== 'https:') return { action: 'deny' }
} catch {
return { action: 'deny' } // unparseable URL — never open it
}
return {
action: 'allow',
overrideBrowserWindowOptions: {
autoHideMenuBar: true,
webPreferences: { partition: PARTITION, sandbox: true, contextIsolation: true, nodeIntegration: false }
}
}
})
// Confine every navigation/popup to web URLs (recursively, so OAuth/SSO
// popups sharing the cookie partition are covered too), and deny all gated
// web permissions — signing in needs no camera/mic/geolocation/etc., and the
// page is untrusted. (audit T4)
hardenLoginWebContents(win.webContents)
win.webContents.session.setPermissionRequestHandler((_wc, _permission, cb) => cb(false))
win.on('closed', () => {
loginWindow = null
+21 -8
View File
@@ -1,27 +1,40 @@
import { app, shell, type BrowserWindow } from 'electron'
import { existsSync, readFileSync } from 'fs'
import { existsSync, openSync, readSync, closeSync } from 'fs'
import { join } from 'path'
import { assertHttpUrl } from './url'
/** Only ever forward http(s) targets into the app — same restriction the
* external-link window-open handler in index.ts applies to in-page links. */
* external-link window-open handler in index.ts applies to in-page links.
* Delegates to the download path's guard so an incoming deep-link target is
* validated AND parser-normalised (no embedded tabs/newlines/control chars
* reach the renderer); returns null instead of throwing for the argv scan.
* (audit T3 / F5) */
function asHttpUrl(candidate: string): string | null {
try {
const u = new URL(candidate)
return u.protocol === 'http:' || u.protocol === 'https:' ? candidate : null
return assertHttpUrl(candidate)
} catch {
return null
}
}
/** Reads a Windows Internet Shortcut (.url) file's target — what Explorer's
* "Send to" menu hands us when the user sends a saved link to AeroFetch. */
* "Send to" menu hands us when the user sends a saved link to AeroFetch.
* Only the first 64 KB is read: a real shortcut is a few hundred bytes, so this
* caps memory and limits exposure if argv points at a pathological or oversized
* file that merely ends in `.url`. (audit T5) */
const MAX_URL_FILE_BYTES = 64 * 1024
function readUrlShortcut(path: string): string | null {
let fd: number | null = null
try {
const text = readFileSync(path, 'utf8')
const match = /^URL=(.+)$/im.exec(text)
fd = openSync(path, 'r')
const buf = Buffer.alloc(MAX_URL_FILE_BYTES)
const bytes = readSync(fd, buf, 0, buf.length, 0)
const match = /^URL=(.+)$/im.exec(buf.toString('utf8', 0, bytes))
return match ? asHttpUrl(match[1].trim()) : null
} catch {
return null
} finally {
if (fd !== null) closeSync(fd)
}
}
@@ -32,7 +45,7 @@ function readUrlShortcut(path: string): string | null {
*/
export function extractIncomingUrl(argv: string[]): string | null {
for (const arg of argv) {
if (arg.startsWith('aerofetch://')) {
if (/^aerofetch:\/\//i.test(arg)) {
try {
const target = new URL(arg).searchParams.get('url')
const valid = target && asHttpUrl(target)
+54 -19
View File
@@ -1,15 +1,23 @@
import { spawn, execFile, type ChildProcess } from 'child_process'
import { existsSync } from 'fs'
import { join } from 'path'
import { app, BrowserWindow, Notification, type WebContents } from 'electron'
import { getYtdlpPath, getBinDir, getAria2cPath, getFfmpegPath, getFfprobePath } from './binaries'
import { getSettings, getDownloadArchivePath } from './settings'
import { BrowserWindow, Notification, type WebContents } from 'electron'
import {
getYtdlpPath,
getBinDir,
getAria2cPath,
getFfmpegPath,
getFfprobePath,
getSystem32Path
} from './binaries'
import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings'
import { getCookiesFilePath } from './cookies'
import { listTemplates } from './templates'
import { assertHttpUrl } from './url'
import { isSafeOutputDir } from './validation'
import {
buildArgs,
parseExtraArgs,
selectExtraArgs,
formatCommandLine,
collectionOutputTemplate
} from './buildArgs'
@@ -151,21 +159,34 @@ function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
// --- Argv construction (shared by startDownload and the command preview) ---
// A per-download override (opts.extraArgs, even '') always wins; otherwise
// fall back to the persisted default template when custom-command mode is on.
// A per-download override (opts.extraArgs, even '') wins over the persisted
// default template — but BOTH are gated on the customCommandEnabled consent flag
// (see selectExtraArgs / audit F2). The gate is enforced here in main, not just
// in the renderer UI, so the renderer can't be trusted to apply it.
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 []
return selectExtraArgs({
customCommandEnabled: settings.customCommandEnabled,
perDownloadExtraArgs: opts.extraArgs,
defaultTemplateId: settings.defaultTemplateId,
templates: listTemplates()
})
}
/** 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')
// Output dir resolution: a per-download override wins, then the user's explicit
// per-kind folder (Settings → Video/Audio folder), and finally — when that's
// blank — the per-kind default (Documents\Video for video, Documents\Audio for audio).
//
// A per-download outputDir override must clear the same safety check the persisted
// setting does (absolute path only); a renderer-supplied override is otherwise
// untrusted — it dictates where downloaded files get written. An unsafe override is
// ignored in favour of the per-kind folder, then the per-kind default. (audit F4)
const override = opts.outputDir?.trim()
const safeOverride = override && isSafeOutputDir(override) ? override : ''
const perKindDir = (opts.kind === 'audio' ? settings.audioDir : settings.videoDir)?.trim()
const outDir = safeOverride || perKindDir || getDefaultMediaDir(opts.kind)
// A collection (media-manager) download is filed into <channel>/<playlist>/
// <NNN> - <title> folders; an ordinary download uses the flat filenameTemplate.
const filenameTemplate = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
@@ -198,13 +219,16 @@ export function buildCommand(opts: StartDownloadOptions): string[] {
/** Build the exact command line for the current form state, without running it. */
export function previewCommand(opts: StartDownloadOptions): CommandPreviewResult {
let normalized: StartDownloadOptions
try {
assertHttpUrl(opts.url)
// Use the parser-normalised URL (audit F5), so the previewed command matches
// exactly what startDownload would spawn.
normalized = { ...opts, url: assertHttpUrl(opts.url) }
} catch (e) {
return { ok: false, error: (e as Error).message }
}
try {
return { ok: true, command: formatCommandLine(getYtdlpPath(), buildCommand(opts)) }
return { ok: true, command: formatCommandLine(getYtdlpPath(), buildCommand(normalized)) }
} catch (e) {
return { ok: false, error: (e as Error).message }
}
@@ -239,9 +263,13 @@ export function startDownload(
`Add the ffmpeg build's binaries to resources/bin/ (see the README there).`
}
}
// Reject anything that isn't an http(s) URL before it reaches yt-dlp's argv.
// Reject anything that isn't an http(s) URL before it reaches yt-dlp's argv,
// and replace opts.url with the parser-normalised form so the exact string we
// validated is the one that gets spawned/probed — not a raw variant carrying
// interior tabs/newlines or leading control chars that URL parsing silently
// tolerates. (audit F5)
try {
assertHttpUrl(opts.url)
opts = { ...opts, url: assertHttpUrl(opts.url) }
} catch (e) {
return { ok: false, error: (e as Error).message }
}
@@ -344,8 +372,15 @@ export function cancelDownload(id: string): void {
rec.canceled = true
const pid = rec.child.pid
if (pid != null) {
// Kill the whole tree (/T) so the spawned ffmpeg child dies too.
execFile('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true }, () => {})
// Kill the whole tree (/T) so the spawned ffmpeg child dies too. Resolve
// taskkill from System32 by absolute path, never the bare name, so a planted
// taskkill.exe on PATH / in the CWD can't run in its place. (audit F3)
execFile(
getSystem32Path('taskkill.exe'),
['/pid', String(pid), '/T', '/F'],
{ windowsHide: true },
() => {}
)
} else {
rec.child.kill()
}
+35 -1
View File
@@ -13,7 +13,7 @@ import {
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
import { probeMedia } from './probe'
import { startDownload, cancelDownload, previewCommand } from './download'
import { getSettings, setSettings } from './settings'
import { getSettings, setSettings, ensureMediaDirs } from './settings'
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
import { listTemplates, saveTemplate, removeTemplate } from './templates'
import { setupPortableData } from './portable'
@@ -90,6 +90,25 @@ function getSystemThemeInfo(): SystemThemeInfo {
}
}
// Web permissions a download manager never needs. They're denied for the app
// window as defence-in-depth (audit T6): even if the renderer were compromised
// (e.g. XSS via remote video metadata) it can't open the camera/mic, read
// location, or reach USB/HID/serial/Bluetooth devices — none of which the IPC
// surface grants either. Clipboard (paste/copy) and everything else is left to
// the default so the app's own features keep working.
const DENIED_PERMISSIONS = new Set([
'media', // camera + microphone
'geolocation',
'midi',
'midiSysex',
'hid',
'serial',
'usb',
'bluetooth',
'speaker-selection',
'idle-detection'
])
function createWindow(): void {
const win = new BrowserWindow({
width: 920,
@@ -143,6 +162,15 @@ function createWindow(): void {
// away from it (defence in depth; HMR uses websockets, not navigation).
win.webContents.on('will-navigate', (e) => e.preventDefault())
// Deny the sensitive hardware/location web permissions the app never uses, so
// a compromised renderer can't escalate to capabilities the IPC surface
// doesn't grant. Both the async request and the sync check are covered. (audit T6)
const ses = win.webContents.session
ses.setPermissionRequestHandler((_wc, permission, callback) =>
callback(!DENIED_PERMISSIONS.has(permission))
)
ses.setPermissionCheckHandler((_wc, permission) => !DENIED_PERMISSIONS.has(permission))
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
@@ -151,6 +179,8 @@ function createWindow(): void {
}
function registerIpcHandlers(): void {
ipcMain.handle(IpcChannels.appVersion, () => app.getVersion())
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
@@ -303,6 +333,10 @@ if (isPrimaryInstance) {
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.aerofetch.app')
// Create the default Documents\Video and Documents\Audio destinations so they
// exist from first launch (downloads are routed into them by kind).
ensureMediaDirs()
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
+3 -2
View File
@@ -83,8 +83,9 @@ export async function indexSource(
url: string,
onProgress: (p: IndexProgress) => void
): Promise<IndexSourceResult> {
let normalizedUrl: string
try {
assertHttpUrl(url)
normalizedUrl = assertHttpUrl(url) // normalised form for spawning (audit F5)
} catch (e) {
return { ok: false, error: (e as Error).message }
}
@@ -146,7 +147,7 @@ export async function indexSource(
// resolve to a playlist when probed. A lone video has no entries → error.
kind = cls?.kind ?? 'playlist'
onProgress({ url, phase: 'uploads', message: 'Indexing playlist…' })
const data = await probeFlat(cls?.base ?? url)
const data = await probeFlat(cls?.base ?? normalizedUrl)
const entries = data.entries ?? []
if (entries.length === 0) {
return {
+21 -1
View File
@@ -34,7 +34,9 @@ export interface RawEntry {
export 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}`
// e.id is untrusted JSON from yt-dlp, so percent-encode it rather than splicing
// it raw into the query string (audit T2). A normal 11-char id is unaffected.
if (e.id) return `https://www.youtube.com/watch?v=${encodeURIComponent(e.id)}`
return null
}
@@ -138,6 +140,24 @@ export function buildFeedUrl(kind: SourceKind, ytId: string | undefined): string
return `https://www.youtube.com/feeds/videos.xml?${param}=${encodeURIComponent(ytId)}`
}
/**
* Guard for the watched-source RSS pre-check (audit T7). The only feed AeroFetch
* ever builds is a YouTube videos.xml feed (see buildFeedUrl), so the sync refuses
* to fetch anything else — this keeps a hand-edited / corrupted sources.json from
* pointing fetch() at an internal service, a cloud-metadata endpoint, or any other
* arbitrary host (SSRF). Requires https, the youtube.com host (www optional), and
* the exact feed path; the channel_id/playlist_id query is free.
*/
export function isYouTubeFeedUrl(url: string): boolean {
try {
const u = new URL(url)
const host = u.hostname.replace(/^www\./i, '').toLowerCase()
return u.protocol === 'https:' && host === 'youtube.com' && u.pathname === '/feeds/videos.xml'
} catch {
return false
}
}
/** Extract the video ids from a YouTube RSS/Atom feed body (the <yt:videoId> tags). */
export function parseRssVideoIds(xml: string): string[] {
const ids: string[] = []
+3 -2
View File
@@ -118,8 +118,9 @@ export function probeMedia(url: string): Promise<ProbeResult> {
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
})
}
let target: string
try {
assertHttpUrl(url)
target = assertHttpUrl(url) // normalised form (audit F5)
} catch (e) {
return Promise.resolve({ ok: false, error: (e as Error).message })
}
@@ -128,7 +129,7 @@ export function probeMedia(url: string): Promise<ProbeResult> {
execFile(
ytdlp,
// `--` terminates option parsing so the URL can never be read as a flag.
['-J', '--flat-playlist', '--no-warnings', '--', url],
['-J', '--flat-playlist', '--no-warnings', '--', target],
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024, timeout: 60_000 },
(err, stdout, stderr) => {
if (err) {
+4 -1
View File
@@ -10,6 +10,7 @@
*/
import { execFile } from 'child_process'
import { getSystem32Path } from './binaries'
import type { ScheduledSyncStatus } from '@shared/ipc'
const TASK_NAME = 'AeroFetchDailySync'
@@ -18,7 +19,9 @@ export const SYNC_FLAG = '--sync'
function schtasks(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
execFile('schtasks', args, { windowsHide: true }, (err, stdout, stderr) => {
// Resolve schtasks from System32 by absolute path, not the bare name, so a
// planted schtasks.exe on PATH / in the CWD can't be invoked instead. (audit F3)
execFile(getSystem32Path('schtasks.exe'), args, { windowsHide: true }, (err, stdout, stderr) => {
const code = err ? ((err as { code?: number }).code ?? 1) : 0
resolve({ code, stdout: String(stdout), stderr: String(stderr) })
})
+40 -7
View File
@@ -1,5 +1,6 @@
import { app } from 'electron'
import { join } from 'path'
import { mkdirSync } from 'fs'
import Store from 'electron-store'
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
import {
@@ -16,7 +17,10 @@ import {
} from '@shared/ipc'
const DEFAULTS: Settings = {
outputDir: '', // resolved to the OS Downloads folder on first read
// Both blank by default → downloads land in Documents\Video / Documents\Audio
// (see getDefaultMediaDir). A non-empty value is an explicit per-kind override.
videoDir: '',
audioDir: '',
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best (MP3)',
@@ -45,6 +49,31 @@ export function getDownloadArchivePath(): string {
return join(app.getPath('userData'), 'download-archive.txt')
}
/**
* The default per-kind download destination: video → Documents\Video,
* audio → Documents\Audio. Used when the user hasn't set an explicit output
* folder (Settings → Download folder), so downloads are sorted by type.
*/
export function getDefaultMediaDir(kind: 'video' | 'audio'): string {
return join(app.getPath('documents'), kind === 'audio' ? 'Audio' : 'Video')
}
/**
* Create the Documents\Video and Documents\Audio folders up front (called once
* at startup) so they exist the moment the app opens, not just after the first
* download. Best-effort: yt-dlp also creates the output dir at download time, so
* a failure here (read-only Documents, redirected folder) is non-fatal.
*/
export function ensureMediaDirs(): void {
for (const kind of ['video', 'audio'] as const) {
try {
mkdirSync(getDefaultMediaDir(kind), { recursive: true })
} catch {
/* non-fatal — the download path will be created on demand instead */
}
}
}
// 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.
@@ -100,10 +129,9 @@ export function getSettings(): Settings {
// `set`, so only write when something actually changed — otherwise this churns
// the settings file on every read. (audit P1)
const cur = s.store
if (!cur.outputDir) {
// Fill in the real Downloads path the first time, and persist it once.
s.set('outputDir', app.getPath('downloads'))
}
// videoDir/audioDir are intentionally left blank by default — an empty value
// routes that kind into Documents\Video / Documents\Audio (see buildCommand).
// Only an explicit user choice (Settings → folders) overrides that.
// Migrate settings files that predate downloadOptions (or hold a partial one),
// but only persist when sanitizing actually altered the stored value.
const sanitized = sanitizeOptions(cur.downloadOptions)
@@ -187,8 +215,13 @@ export function setSettings(partial: Partial<Settings>): Settings {
// group (it merges field changes locally before calling setSettings).
s.set('downloadOptions', sanitizeOptions(value))
break
case 'outputDir':
if (typeof value === 'string' && isSafeOutputDir(value.trim())) s.set('outputDir', value.trim())
case 'videoDir':
case 'audioDir':
// An empty string is allowed — it clears the override and restores the
// Documents\Video / Documents\Audio default for that kind.
if (typeof value === 'string' && (value.trim() === '' || isSafeOutputDir(value.trim()))) {
s.set(key, value.trim())
}
break
case 'filenameTemplate':
if (typeof value === 'string' && isSafeFilenameTemplate(value.trim())) {
+5 -1
View File
@@ -7,11 +7,15 @@
import { listSources, listMediaItems } from './sources'
import { indexSource } from './indexer'
import { parseRssVideoIds } from './indexerCore'
import { parseRssVideoIds, isYouTubeFeedUrl } from './indexerCore'
import type { IndexProgress, MediaItem, SyncResult } from '@shared/ipc'
/** Fetch a YouTube RSS feed and return its recent video ids. Throws on failure. */
async function fetchFeedIds(feedUrl: string): Promise<string[]> {
// Only ever fetch a genuine YouTube feed — refuse an arbitrary host that a
// corrupted sources.json might carry (SSRF guard, audit T7). A throw here is
// caught by the caller, which then falls back to a full yt-dlp re-index.
if (!isYouTubeFeedUrl(feedUrl)) throw new Error('Refusing to fetch a non-YouTube feed URL.')
const res = await fetch(feedUrl, { signal: AbortSignal.timeout(15_000) })
if (!res.ok) throw new Error(`feed responded ${res.status}`)
return parseRssVideoIds(await res.text())
+9 -3
View File
@@ -10,17 +10,23 @@
* (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.
*
* Returns the parser-NORMALISED URL (`u.href`), not the raw input (audit F5).
* The WHATWG URL parser silently tolerates interior tab/newline characters and
* leading C0 control bytes (which `String.trim()` does not strip), so returning
* the raw string could hand a downstream consumer — argv, or the sign-in
* window's loadURL — a value subtly different from the one actually validated.
* Emitting `u.href` guarantees callers use exactly the URL that passed the check.
*/
export function assertHttpUrl(raw: string): string {
const trimmed = (raw ?? '').trim()
let u: URL
try {
u = new URL(trimmed)
u = new URL((raw ?? '').trim())
} catch {
throw new Error('That doesnt look like a valid URL.')
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
throw new Error('Only http and https links are supported.')
}
return trimmed
return u.href
}
+17 -7
View File
@@ -13,15 +13,21 @@ import type { HistoryEntry, ErrorLogEntry, CommandTemplate, Source, MediaItem }
/**
* A filenameTemplate is joined onto the output directory and handed to yt-dlp's
* `-o`. Reject anything that could write outside that directory — an absolute
* path, or any `..` path segment — so a malicious backup/settings write can't
* traverse out of the chosen folder (e.g. '%(title)s\..\..\win32.exe'). The
* template still legitimately contains yt-dlp `%(field)s` tokens and `/` or `\`
* for sub-folders, which are fine.
* `-o`. Reject anything that could write outside that directory so a malicious
* backup/settings write can't traverse out of the chosen folder (e.g.
* '%(title)s\..\..\win32.exe'). Legitimate templates still contain yt-dlp
* `%(field)s` tokens and `/` or `\` for sub-folders, which are fine.
*
* Two Windows-specific bypasses are guarded beyond the obvious cases (audit T1):
* - drive-relative prefixes like 'C:foo' — `path.isAbsolute` returns FALSE for
* these, yet they escape the output dir, so a leading drive letter is rejected.
* - a '..' segment dressed up with trailing dots/spaces ('.. ', '.. .') —
* Windows silently trims those, so an exact `=== '..'` check would miss them.
*/
const TRAVERSAL_SEGMENT = /^[. ]*\.\.[. ]*$/
export function isSafeFilenameTemplate(template: string): boolean {
if (isAbsolute(template)) return false
return !template.split(/[\\/]/).some((segment) => segment === '..')
if (isAbsolute(template) || /^[a-zA-Z]:/.test(template)) return false
return !template.split(/[\\/]/).some((segment) => TRAVERSAL_SEGMENT.test(segment))
}
/** An output directory must be an absolute path ('' resolves to OS Downloads). */
@@ -88,6 +94,10 @@ export function isValidSource(o: unknown): o is Source {
typeof s.addedAt === 'number' &&
typeof s.itemCount === 'number' &&
isOptionalString(s.channel) &&
// feedUrl drives a network fetch in the sync, so validate its shape here too
// (audit T7); the host is additionally restricted at the fetch boundary.
isOptionalString(s.feedUrl) &&
(s.watched === undefined || typeof s.watched === 'boolean') &&
(s.lastIndexedAt === undefined || typeof s.lastIndexedAt === 'number')
)
}
+14 -1
View File
@@ -1,7 +1,12 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries'
import type { YtdlpVersionResult, YtdlpUpdateChannel, YtdlpUpdateResult } from '@shared/ipc'
import {
isYtdlpUpdateChannel,
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult
} from '@shared/ipc'
/**
* Step-1 spike: spawn the bundled yt-dlp and read back `--version`.
@@ -38,6 +43,14 @@ export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
* a per-user install; it would fail under a locked-down system install.
*/
export 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 Promise.resolve({ ok: false, error: 'Unsupported update channel.' })
}
const ytdlpPath = getYtdlpPath()
if (!existsSync(ytdlpPath)) {
+3
View File
@@ -28,6 +28,9 @@ import {
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
const api = {
/** AeroFetch's own version string (e.g. '0.3.1'). */
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IpcChannels.appVersion),
getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
ipcRenderer.invoke(IpcChannels.ytdlpVersion),
+26 -6
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
import { Sidebar, type TabValue } from './components/Sidebar'
import { DownloadsView } from './components/DownloadsView'
@@ -8,7 +8,7 @@ import { SettingsView } from './components/SettingsView'
import { Onboarding } from './components/Onboarding'
import { getTheme, pageBackground } from './theme'
import { useSettings } from './store/settings'
import { useSystemTheme } from './store/systemTheme'
import { useResolvedDark } from './store/systemTheme'
const useStyles = makeStyles({
provider: {
@@ -32,9 +32,26 @@ function App(): React.JSX.Element {
const theme = useSettings((s) => s.theme)
const accentColor = useSettings((s) => s.accentColor)
const updateSettings = useSettings((s) => s.update)
const systemPrefersDark = useSystemTheme((s) => s.shouldUseDarkColors)
const isDark = theme === 'system' ? systemPrefersDark : theme === 'dark'
const isDark = useResolvedDark()
const [tab, setTab] = useState<TabValue>('downloads')
// AeroFetch's own version, shown in the sidebar. Loaded once over IPC.
const [version, setVersion] = useState('')
useEffect(() => {
window.api?.getAppVersion?.().then(setVersion).catch(() => {})
}, [])
// Sidebar collapse, persisted across launches in localStorage.
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem('aerofetch.sidebarCollapsed') === '1'
)
function toggleCollapsed(): void {
setCollapsed((c) => {
const next = !c
localStorage.setItem('aerofetch.sidebarCollapsed', next ? '1' : '0')
return next
})
}
// Gate on `loaded` so a returning user's real settings never get clobbered
// by a one-frame flash of the (default-false) onboarding state.
const loaded = useSettings((s) => s.loaded)
@@ -60,9 +77,12 @@ function App(): React.JSX.Element {
<Sidebar
tab={tab}
onTabChange={setTab}
theme={theme}
isDark={isDark}
followingSystem={theme === 'system'}
onToggleTheme={() => updateSettings({ theme: isDark ? 'light' : 'dark' })}
onSetTheme={(mode) => updateSettings({ theme: mode })}
version={version}
collapsed={collapsed}
onToggleCollapsed={toggleCollapsed}
/>
<main className={styles.content}>
+5 -290
View File
@@ -3,8 +3,6 @@ import {
Input,
Button,
Checkbox,
Switch,
Field,
Spinner,
Text,
Caption1,
@@ -16,36 +14,19 @@ import {
import {
ArrowDownloadRegular,
ClipboardPasteRegular,
FolderRegular,
SearchRegular,
VideoClipRegular,
MusicNote2Regular,
ErrorCircleRegular,
LinkRegular,
DismissRegular,
OptionsRegular,
ChevronDownRegular,
ChevronUpRegular,
AppsListRegular,
CodeRegular,
EyeRegular,
EyeOffRegular,
CopyRegular
AppsListRegular
} from '@fluentui/react-icons'
import type {
MediaInfo,
FormatOption,
PlaylistInfo,
DownloadOptions,
CommandPreviewResult,
StartDownloadOptions
} from '@shared/ipc'
import type { MediaInfo, FormatOption, PlaylistInfo } from '@shared/ipc'
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
import { useSettings } from '../store/settings'
import { useTemplates } from '../store/templates'
import { Select } from './Select'
import { Hint } from './Hint'
import { DownloadOptionsForm } from './DownloadOptionsForm'
/** A quick heuristic for "this clipboard text is a link worth offering". */
function looksLikeUrl(text: string): boolean {
@@ -184,33 +165,6 @@ const useStyles = makeStyles({
spacer: {
flexGrow: 1
},
// --- per-download options panel ---
optionsBar: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
optionsPanel: {
padding: '14px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
// --- command preview ---
previewPanel: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '14px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
previewCommandText: {
fontFamily: tokens.fontFamilyMonospace,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all'
},
// --- playlist selection ---
plPanel: {
display: 'flex',
@@ -258,63 +212,21 @@ const useStyles = makeStyles({
},
plItemMeta: {
color: tokens.colorNeutralForeground3
},
folder: {
display: 'flex',
alignItems: 'center',
gap: '6px',
color: tokens.colorNeutralForeground3,
maxWidth: '360px'
},
folderPath: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
})
export function DownloadBar(): React.JSX.Element {
const styles = useStyles()
const addFromUrl = useDownloads((s) => s.addFromUrl)
const outputDir = useSettings((s) => s.outputDir)
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
const settingsLoaded = useSettings((s) => s.loaded)
const defaultKind = useSettings((s) => s.defaultKind)
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
const downloadOptions = useSettings((s) => s.downloadOptions)
const [url, setUrl] = useState('')
const [kind, setKind] = useState<MediaKind>('video')
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
// Per-download options override (null = use the persisted defaults).
const [override, setOverride] = useState<DownloadOptions | null>(null)
const [showOptions, setShowOptions] = useState(false)
const effectiveOptions = override ?? downloadOptions
// Private mode — sticky like an incognito tab; the download still runs and
// appears in the queue, but its completion is never recorded to history.
const [incognito, setIncognito] = useState(false)
// Per-download custom-command override: undefined = defer to the settings
// default (customCommandEnabled + defaultTemplateId); null = explicit
// "None" for just this download; a string = a chosen template id override.
const [templateOverride, setTemplateOverride] = useState<string | null | undefined>(undefined)
const [showCustomCommand, setShowCustomCommand] = useState(false)
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const settingsDefaultTemplateId = useSettings((s) => s.defaultTemplateId)
const templates = useTemplates((s) => s.templates)
const effectiveTemplateId =
templateOverride !== undefined
? templateOverride
: customCommandEnabled
? settingsDefaultTemplateId
: null
const [previewResult, setPreviewResult] = useState<CommandPreviewResult | null>(null)
const [previewing, setPreviewing] = useState(false)
// Apply the saved default format once, when persisted settings first arrive.
const appliedDefaults = useRef(false)
useEffect(() => {
@@ -393,7 +305,6 @@ export function DownloadBar(): React.JSX.Element {
setSuggestion(null)
}
const folder = outputDir || 'your Downloads folder'
const usingFormats = kind === 'video' && info !== null && info.formats.length > 0
const selectedFormat: FormatOption | undefined = usingFormats
? info.formats.find((f) => f.id === formatId) ?? info.formats[0]
@@ -468,61 +379,6 @@ export function DownloadBar(): React.JSX.Element {
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
}
// Resolves the per-download custom-command override to the raw extra-args
// string sent to main; undefined defers to the settings default there.
function resolveExtraArgs(): string | undefined {
if (templateOverride === undefined) return undefined
if (templateOverride === null) return ''
return templates.find((t) => t.id === templateOverride)?.args ?? ''
}
// The StartDownloadOptions the current form state would produce — shared by
// the real download() call and the command-preview button so they can never
// drift apart.
function currentStartOptions(): StartDownloadOptions {
const base: StartDownloadOptions = {
id: 'preview',
url: url.trim(),
kind,
quality,
outputDir: outputDir || undefined,
options: override ?? undefined,
extraArgs: resolveExtraArgs()
}
if (usingFormats && selectedFormat) {
return {
...base,
kind: 'video',
quality: selectedFormat.label,
formatId: selectedFormat.id,
formatHasAudio: selectedFormat.hasAudio
}
}
return base
}
async function previewCommand(): Promise<void> {
if (!url.trim() || previewing) return
setPreviewing(true)
setPreviewResult(null)
try {
setPreviewResult(await window.api.previewCommand(currentStartOptions()))
} catch (e) {
setPreviewResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setPreviewing(false)
}
}
async function copyPreview(): Promise<void> {
if (!previewResult?.command) return
try {
await navigator.clipboard.writeText(previewResult.command)
} catch {
/* clipboard blocked — ignore in preview */
}
}
function download(): void {
const trimmed = url.trim()
if (!trimmed) return
@@ -543,18 +399,10 @@ export function DownloadBar(): React.JSX.Element {
id: selectedFormat.id,
hasAudio: selectedFormat.hasAudio,
label: selectedFormat.label
},
options: override ?? undefined,
extraArgs: resolveExtraArgs(),
incognito
}
})
} else {
addFromUrl(trimmed, kind, quality, {
...meta,
options: override ?? undefined,
extraArgs: resolveExtraArgs(),
incognito
})
addFromUrl(trimmed, kind, quality, meta)
}
setUrl('')
@@ -568,10 +416,7 @@ export function DownloadBar(): React.JSX.Element {
addFromUrl(e.url, kind, quality, {
title: e.title,
channel: e.uploader,
durationLabel: e.durationLabel,
options: override ?? undefined,
extraArgs: resolveExtraArgs(),
incognito
durationLabel: e.durationLabel
})
}
setUrl('')
@@ -769,136 +614,6 @@ export function DownloadBar(): React.JSX.Element {
</Button>
)}
</div>
<div className={styles.optionsBar}>
{incognito ? <EyeOffRegular /> : <EyeRegular />}
<Switch
checked={incognito}
onChange={(_, d) => setIncognito(d.checked)}
label={incognito ? 'Private — wont be saved to history' : 'Private mode'}
/>
</div>
<div className={styles.optionsBar}>
<Button
appearance="subtle"
size="small"
icon={<OptionsRegular />}
iconPosition="before"
onClick={() => setShowOptions((v) => !v)}
>
Options {showOptions ? <ChevronUpRegular /> : <ChevronDownRegular />}
</Button>
{override && (
<>
<Caption1 className={styles.previewMeta}>Customised for this download</Caption1>
<Button appearance="subtle" size="small" onClick={() => setOverride(null)}>
Reset to defaults
</Button>
</>
)}
</div>
{showOptions && (
<div className={styles.optionsPanel}>
<DownloadOptionsForm value={effectiveOptions} onChange={(o) => setOverride(o)} />
</div>
)}
<div className={styles.optionsBar}>
<Button
appearance="subtle"
size="small"
icon={<CodeRegular />}
iconPosition="before"
onClick={() => setShowCustomCommand((v) => !v)}
>
Custom command {showCustomCommand ? <ChevronUpRegular /> : <ChevronDownRegular />}
</Button>
{templateOverride !== undefined && (
<>
<Caption1 className={styles.previewMeta}>Customised for this download</Caption1>
<Button appearance="subtle" size="small" onClick={() => setTemplateOverride(undefined)}>
Reset to default
</Button>
</>
)}
<div className={styles.spacer} />
<Hint label="Build and show the exact yt-dlp command line" placement="top" align="end">
<Button
appearance="subtle"
size="small"
icon={previewing ? <Spinner size="tiny" /> : <EyeRegular />}
iconPosition="before"
onClick={previewCommand}
disabled={!url.trim() || previewing}
>
Preview command
</Button>
</Hint>
</div>
{showCustomCommand && (
<div className={styles.optionsPanel}>
<Field label="Template" hint="Extra yt-dlp flags layered onto this download, after every other option.">
<Select
aria-label="Custom command template"
value={effectiveTemplateId ?? 'none'}
options={[
{ value: 'none', label: 'None' },
...templates.map((t) => ({ value: t.id, label: t.name }))
]}
onChange={(v) => setTemplateOverride(v === 'none' ? null : v)}
/>
</Field>
{templates.length === 0 && (
<Caption1 className={styles.previewMeta}>
No templates yet add one in Settings Custom commands.
</Caption1>
)}
</div>
)}
{previewResult && (
<div className={styles.previewPanel}>
<div className={styles.plHeader}>
<Caption1 className={styles.plHeaderText}>
{previewResult.ok ? 'Command preview' : 'Could not build the command'}
</Caption1>
{previewResult.ok && (
<Button size="small" icon={<CopyRegular />} onClick={copyPreview}>
Copy
</Button>
)}
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => setPreviewResult(null)}
aria-label="Dismiss preview"
/>
</div>
{previewResult.ok ? (
<Text className={styles.previewCommandText}>{previewResult.command}</Text>
) : (
<Caption1 className={mergeClasses(styles.previewMeta, styles.errorRow)}>
{previewResult.error}
</Caption1>
)}
</div>
)}
<div className={styles.folder}>
<FolderRegular />
<Caption1 className={styles.folderPath} title={folder}>
Saving to {folder}
</Caption1>
<Hint label="Change download folder" placement="top" align="start">
<Button size="small" appearance="transparent" onClick={chooseOutputDir}>
Change
</Button>
</Hint>
</div>
</div>
)
}
+13 -3
View File
@@ -30,13 +30,15 @@ const useStyles = makeStyles({
},
top: { bottom: 'calc(100% + 6px)' },
bottom: { top: 'calc(100% + 6px)' },
right: { left: 'calc(100% + 6px)', top: '50%', transform: 'translateY(-50%)' },
left: { right: 'calc(100% + 6px)', top: '50%', transform: 'translateY(-50%)' },
alignStart: { left: 0 },
alignEnd: { right: 0 }
})
interface HintProps {
label: string
placement?: 'top' | 'bottom'
placement?: 'top' | 'bottom' | 'left' | 'right'
align?: 'start' | 'end'
children: React.ReactNode
}
@@ -56,8 +58,16 @@ export function Hint({
aria-hidden
className={mergeClasses(
styles.bubble,
placement === 'bottom' ? styles.bottom : styles.top,
align === 'end' ? styles.alignEnd : styles.alignStart
placement === 'bottom'
? styles.bottom
: placement === 'right'
? styles.right
: placement === 'left'
? styles.left
: styles.top,
// start/end alignment only applies to vertical (top/bottom) placements
(placement === 'top' || placement === 'bottom') &&
(align === 'end' ? styles.alignEnd : styles.alignStart)
)}
>
{label}
+10 -12
View File
@@ -14,8 +14,6 @@ import {
OpenRegular,
FolderRegular,
DeleteRegular,
VideoClipRegular,
MusicNote2Regular,
HistoryRegular,
SearchRegular,
ArrowClockwiseRegular,
@@ -24,9 +22,11 @@ import {
} from '@fluentui/react-icons'
import type { HistoryEntry, MediaKind } from '@shared/ipc'
import { useHistory } from '../store/history'
import { useSettings } from '../store/settings'
import { useResolvedDark } from '../store/systemTheme'
import { useDownloads } from '../store/downloads'
import { thumbColors } from '../theme'
import { thumbUrl } from '../thumb'
import { MediaThumb } from './MediaThumb'
import { Hint } from './Hint'
import { Select } from './Select'
@@ -147,7 +147,7 @@ function formatWhen(ts: number): string {
export function HistoryView(): React.JSX.Element {
const styles = useStyles()
const isDark = useSettings((s) => s.theme === 'dark')
const isDark = useResolvedDark()
const tc = thumbColors[isDark ? 'dark' : 'light']
const entries = useHistory((s) => s.entries)
const openFile = useHistory((s) => s.openFile)
@@ -282,7 +282,6 @@ export function HistoryView(): React.JSX.Element {
) : (
<div className={styles.list}>
{filtered.map((h: HistoryEntry) => {
const t = h.kind === 'audio' ? tc.audio : tc.video
return (
<div key={h.id} className={styles.row}>
{selectMode && (
@@ -292,13 +291,12 @@ export function HistoryView(): React.JSX.Element {
aria-label={`Select ${h.title}`}
/>
)}
<div className={styles.thumb} style={{ backgroundColor: t.bg, color: t.fg }}>
{h.kind === 'audio' ? (
<MusicNote2Regular fontSize={22} />
) : (
<VideoClipRegular fontSize={22} />
)}
</div>
<MediaThumb
className={styles.thumb}
src={thumbUrl({ thumbnail: h.thumbnail, url: h.url })}
kind={h.kind}
iconSize={22}
/>
<div className={styles.body}>
<Text className={styles.title}>{h.title}</Text>
<Caption1 className={styles.meta}>
@@ -31,6 +31,8 @@ import type { MediaItem, Source } from '@shared/ipc'
import { useSources, MAX_ENQUEUE_BATCH } from '../store/sources'
import { useSettings } from '../store/settings'
import { useDownloads, type DownloadStatus } from '../store/downloads'
import { thumbUrl } from '../thumb'
import { MediaThumb } from './MediaThumb'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -148,6 +150,11 @@ const useStyles = makeStyles({
gap: '10px',
padding: '5px 4px 5px 18px'
},
rowThumb: {
width: '60px',
height: '34px',
...shorthands.borderRadius(tokens.borderRadiusSmall)
},
rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
rowTitle: {
whiteSpace: 'nowrap',
@@ -494,6 +501,12 @@ export function LibraryView(): React.JSX.Element {
onChange={(_, d) => toggle(it.id, !!d.checked)}
aria-label={`Select ${it.title}`}
/>
<MediaThumb
className={styles.rowThumb}
src={thumbUrl({ url: it.url, videoId: it.videoId })}
kind="video"
iconSize={16}
/>
<div className={styles.rowMain}>
<span className={styles.rowTitle}>
{it.playlistIndex}. {it.title}
@@ -0,0 +1,70 @@
import { useState } from 'react'
import { makeStyles, mergeClasses } from '@fluentui/react-components'
import { VideoClipRegular, MusicNote2Regular } from '@fluentui/react-icons'
import type { MediaKind } from '@shared/ipc'
import { useResolvedDark } from '../store/systemTheme'
import { thumbColors } from '../theme'
const useStyles = makeStyles({
box: {
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden'
},
img: {
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block'
}
})
/**
* A media preview thumbnail: shows the image at `src` when one is available and
* loads, otherwise a kind icon on a quiet neutral tint (the same palette the
* placeholders used before). The load failure is tracked per-src, so a thumbnail
* that arrives later (e.g. resolved after a probe) is retried rather than left on
* the fallback. The caller sizes the box via `className` (width/height/radius).
*/
export function MediaThumb({
src,
kind,
iconSize = 24,
className
}: {
src?: string
kind: MediaKind
iconSize?: number
className?: string
}): React.JSX.Element {
const styles = useStyles()
const isDark = useResolvedDark()
const colors = thumbColors[isDark ? 'dark' : 'light'][kind === 'audio' ? 'audio' : 'video']
const [failedSrc, setFailedSrc] = useState<string | null>(null)
const showImg = !!src && failedSrc !== src
// The neutral tint always backs the box, so there's a placeholder behind the
// image while it loads (and the kind icon stays legible when there's no image).
return (
<div
className={mergeClasses(styles.box, className)}
style={{ backgroundColor: colors.bg, color: colors.fg }}
>
{showImg ? (
<img
className={styles.img}
src={src}
alt=""
loading="lazy"
onError={() => setFailedSrc(src ?? null)}
/>
) : kind === 'audio' ? (
<MusicNote2Regular fontSize={iconSize} />
) : (
<VideoClipRegular fontSize={iconSize} />
)}
</div>
)
}
+8 -23
View File
@@ -4,7 +4,6 @@ import {
Body1,
Caption1,
Field,
Input,
Button,
makeStyles,
tokens,
@@ -12,7 +11,6 @@ import {
} from '@fluentui/react-components'
import {
ArrowDownloadFilled,
FolderRegular,
ClipboardPasteRegular,
HistoryRegular,
OptionsRegular,
@@ -56,13 +54,8 @@ const useStyles = makeStyles({
justifyContent: 'center',
fontSize: '26px'
},
folderRow: {
display: 'flex',
gap: '8px',
alignItems: 'flex-end'
},
folderInput: {
flexGrow: 1
folderNote: {
color: tokens.colorNeutralForeground3
},
tips: {
display: 'flex',
@@ -99,8 +92,6 @@ const TIPS: { icon: React.JSX.Element; text: string }[] = [
export function Onboarding(): React.JSX.Element {
const styles = useStyles()
const outputDir = useSettings((s) => s.outputDir)
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
const update = useSettings((s) => s.update)
return (
@@ -118,18 +109,12 @@ export function Onboarding(): React.JSX.Element {
audio. yt-dlp and ffmpeg are bundled, so there&apos;s nothing else to install.
</Body1>
<Field label="Download folder">
<div className={styles.folderRow}>
<Input
readOnly
className={styles.folderInput}
value={outputDir}
contentBefore={<FolderRegular />}
/>
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
Browse
</Button>
</div>
<Field label="Where downloads go">
<Caption1 className={styles.folderNote}>
Videos save to your <strong>Documents\Video</strong> folder and audio to{' '}
<strong>Documents\Audio</strong>. You can point each to a different folder any time
in Settings.
</Caption1>
</Field>
<div className={styles.tips}>
+8 -13
View File
@@ -15,15 +15,13 @@ import {
OpenRegular,
FolderRegular,
ArrowClockwiseRegular,
VideoClipRegular,
MusicNote2Regular,
CheckmarkCircleFilled,
ErrorCircleFilled,
EyeOffRegular
} from '@fluentui/react-icons'
import { useDownloads, type DownloadItem, type DownloadStatus } from '../store/downloads'
import { useSettings } from '../store/settings'
import { thumbColors } from '../theme'
import { thumbUrl } from '../thumb'
import { MediaThumb } from './MediaThumb'
import { Hint } from './Hint'
const useStyles = makeStyles({
@@ -103,8 +101,6 @@ function pct(progress: number): string {
export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
const styles = useStyles()
const isDark = useSettings((s) => s.theme === 'dark')
const thumb = thumbColors[isDark ? 'dark' : 'light'][item.kind === 'audio' ? 'audio' : 'video']
const cancel = useDownloads((s) => s.cancel)
const remove = useDownloads((s) => s.remove)
const retry = useDownloads((s) => s.retry)
@@ -124,13 +120,12 @@ export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
return (
<div className={styles.root}>
<div className={styles.thumb} style={{ backgroundColor: thumb.bg, color: thumb.fg }}>
{item.kind === 'audio' ? (
<MusicNote2Regular fontSize={28} />
) : (
<VideoClipRegular fontSize={28} />
)}
</div>
<MediaThumb
className={styles.thumb}
src={thumbUrl({ thumbnail: item.thumbnail, url: item.url })}
kind={item.kind}
iconSize={28}
/>
<div className={styles.body}>
<div className={styles.titleRow}>
+39 -5
View File
@@ -170,8 +170,10 @@ const UPDATE_CHANNEL_OPTIONS = [
export function SettingsView(): React.JSX.Element {
const styles = useStyles()
const outputDir = useSettings((s) => s.outputDir)
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
const videoDir = useSettings((s) => s.videoDir)
const audioDir = useSettings((s) => s.audioDir)
const chooseDir = useSettings((s) => s.chooseDir)
const clearDir = useSettings((s) => s.clearDir)
const defaultKind = useSettings((s) => s.defaultKind)
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
@@ -328,17 +330,49 @@ export function SettingsView(): React.JSX.Element {
<Subtitle2>Downloads</Subtitle2>
</div>
<Field label="Download folder">
<Field
label="Video folder"
hint="Where video downloads are saved. Leave blank to use Documents\Video."
>
<div className={styles.folderRow}>
<Input
readOnly
className={styles.folderInput}
value={outputDir}
value={videoDir}
placeholder="Documents\Video (default)"
contentBefore={<FolderRegular />}
/>
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
<Button icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
Browse
</Button>
{videoDir && (
<Button appearance="subtle" onClick={() => clearDir('videoDir')}>
Reset
</Button>
)}
</div>
</Field>
<Field
label="Audio folder"
hint="Where audio downloads are saved. Leave blank to use Documents\Audio."
>
<div className={styles.folderRow}>
<Input
readOnly
className={styles.folderInput}
value={audioDir}
placeholder="Documents\Audio (default)"
contentBefore={<FolderRegular />}
/>
<Button icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}>
Browse
</Button>
{audioDir && (
<Button appearance="subtle" onClick={() => clearDir('audioDir')}>
Reset
</Button>
)}
</div>
</Field>
+186 -42
View File
@@ -1,11 +1,4 @@
import {
Caption1,
Switch,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import { Caption1, makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
import {
ArrowDownloadFilled,
ArrowDownloadRegular,
@@ -13,8 +6,13 @@ import {
LibraryRegular,
SettingsRegular,
WeatherMoonRegular,
WeatherSunnyRegular
WeatherSunnyRegular,
DesktopRegular,
PanelLeftContractRegular,
PanelLeftExpandRegular
} from '@fluentui/react-icons'
import type { ThemeMode } from '@shared/ipc'
import { Hint } from './Hint'
export type TabValue = 'downloads' | 'library' | 'history' | 'settings'
@@ -27,13 +25,48 @@ const useStyles = makeStyles({
gap: '4px',
padding: '16px 12px',
backgroundColor: tokens.colorNeutralBackground1,
borderRight: `1px solid ${tokens.colorNeutralStroke2}`
borderRight: `1px solid ${tokens.colorNeutralStroke2}`,
transition: 'width 0.15s ease'
},
rootCollapsed: {
width: '60px',
alignItems: 'center'
},
topBar: {
display: 'flex',
justifyContent: 'flex-end',
paddingBottom: '2px'
},
topBarCollapsed: {
justifyContent: 'center'
},
iconBtn: {
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground3,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
fontSize: '18px',
cursor: 'pointer',
...shorthands.borderRadius(tokens.borderRadiusMedium),
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover,
color: tokens.colorNeutralForeground2
}
},
brand: {
display: 'flex',
alignItems: 'center',
gap: '11px',
padding: '6px 10px 14px'
padding: '0 10px 14px'
},
brandCollapsed: {
padding: '0 0 12px',
justifyContent: 'center'
},
mark: {
width: '36px',
@@ -64,7 +97,8 @@ const useStyles = makeStyles({
nav: {
display: 'flex',
flexDirection: 'column',
gap: '3px'
gap: '3px',
alignSelf: 'stretch'
},
navItem: {
display: 'flex',
@@ -84,6 +118,10 @@ const useStyles = makeStyles({
backgroundColor: tokens.colorNeutralBackground1Hover
}
},
navItemCollapsed: {
justifyContent: 'center',
padding: '9px 0'
},
navItemActive: {
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2,
@@ -94,22 +132,46 @@ const useStyles = makeStyles({
},
navIcon: {
fontSize: '18px',
flexShrink: 0
flexShrink: 0,
display: 'flex'
},
spacer: {
flexGrow: 1
},
themeRow: {
// --- theme control (expanded): a 3-way Light / Dark / Auto segmented switch ---
themeGroup: {
alignSelf: 'stretch',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
color: tokens.colorNeutralForeground3
width: '100%',
border: `1px solid ${tokens.colorNeutralStroke1}`,
...shorthands.borderRadius(tokens.borderRadiusMedium),
overflow: 'hidden'
},
themeLabel: {
themeSeg: {
flex: 1,
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground2,
display: 'flex',
alignItems: 'center',
gap: '9px'
justifyContent: 'center',
gap: '5px',
padding: '7px 4px',
fontSize: tokens.fontSizeBase200,
fontFamily: tokens.fontFamilyBase,
cursor: 'pointer',
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover
}
},
themeSegActive: {
backgroundColor: tokens.colorBrandBackground,
color: tokens.colorNeutralForegroundOnBrand,
fontWeight: tokens.fontWeightSemibold,
':hover': {
backgroundColor: tokens.colorBrandBackgroundHover
}
}
})
@@ -120,67 +182,149 @@ const NAV: { value: TabValue; label: string; icon: React.JSX.Element }[] = [
{ value: 'settings', label: 'Settings', icon: <SettingsRegular /> }
]
const THEMES: { value: ThemeMode; label: string; icon: React.JSX.Element }[] = [
{ value: 'light', label: 'Light', icon: <WeatherSunnyRegular /> },
{ value: 'dark', label: 'Dark', icon: <WeatherMoonRegular /> },
{ value: 'system', label: 'Auto', icon: <DesktopRegular /> }
]
interface SidebarProps {
tab: TabValue
onTabChange: (t: TabValue) => void
/** the current theme preference: explicit light/dark, or 'system' to follow the OS */
theme: ThemeMode
/** whether the resolved theme is currently dark (drives the collapsed toggle icon) */
isDark: boolean
/** true when theme is 'system' — the quick toggle still works (it sets an explicit mode) */
followingSystem: boolean
onToggleTheme: () => void
onSetTheme: (mode: ThemeMode) => void
/** AeroFetch version string, e.g. '0.3.2' ('' until loaded) */
version: string
collapsed: boolean
onToggleCollapsed: () => void
}
export function Sidebar({
tab,
onTabChange,
theme,
isDark,
followingSystem,
onToggleTheme
onSetTheme,
version,
collapsed,
onToggleCollapsed
}: SidebarProps): React.JSX.Element {
const styles = useStyles()
// Collapsed view shows one button that cycles Light → Dark → Auto.
const order: ThemeMode[] = ['light', 'dark', 'system']
function cycleTheme(): void {
onSetTheme(order[(order.indexOf(theme) + 1) % order.length])
}
const themeIcon =
theme === 'system' ? (
<DesktopRegular fontSize={18} />
) : isDark ? (
<WeatherMoonRegular fontSize={18} />
) : (
<WeatherSunnyRegular fontSize={18} />
)
const themeLabel = theme === 'system' ? 'Auto (system)' : isDark ? 'Dark' : 'Light'
return (
<nav className={styles.root}>
<div className={styles.brand}>
<nav className={mergeClasses(styles.root, collapsed && styles.rootCollapsed)}>
<div className={mergeClasses(styles.topBar, collapsed && styles.topBarCollapsed)}>
<Hint label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} placement="right">
<button
type="button"
className={styles.iconBtn}
onClick={onToggleCollapsed}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-pressed={collapsed}
>
{collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
</button>
</Hint>
</div>
<div className={mergeClasses(styles.brand, collapsed && styles.brandCollapsed)}>
<div className={styles.mark}>
<ArrowDownloadFilled />
</div>
<div className={styles.brandText}>
<span className={styles.brandName}>AeroFetch</span>
<Caption1 className={styles.caption}>yt-dlp frontend</Caption1>
</div>
{!collapsed && (
<div className={styles.brandText}>
<span className={styles.brandName}>AeroFetch</span>
<Caption1 className={styles.caption}>{version ? `v${version}` : 'yt-dlp frontend'}</Caption1>
</div>
)}
</div>
<div className={styles.nav}>
{NAV.map((n) => {
const active = tab === n.value
return (
const btn = (
<button
key={n.value}
type="button"
className={mergeClasses(styles.navItem, active && styles.navItemActive)}
className={mergeClasses(
styles.navItem,
collapsed && styles.navItemCollapsed,
active && styles.navItemActive
)}
style={
active
active && !collapsed
? { boxShadow: `inset 3px 0 0 0 ${tokens.colorCompoundBrandStroke}` }
: undefined
}
onClick={() => onTabChange(n.value)}
aria-current={active ? 'page' : undefined}
aria-label={n.label}
>
<span className={styles.navIcon}>{n.icon}</span>
{n.label}
{!collapsed && n.label}
</button>
)
return collapsed ? (
<Hint key={n.value} label={n.label} placement="right">
{btn}
</Hint>
) : (
btn
)
})}
</div>
<div className={styles.spacer} />
<div className={styles.themeRow}>
<span className={styles.themeLabel}>
{isDark ? <WeatherMoonRegular fontSize={18} /> : <WeatherSunnyRegular fontSize={18} />}
{(isDark ? 'Dark' : 'Light') + (followingSystem ? ' · Auto' : '')}
</span>
<Switch checked={isDark} onChange={onToggleTheme} aria-label="Toggle dark mode" />
</div>
{collapsed ? (
<Hint label={`Theme: ${themeLabel}`} placement="right">
<button
type="button"
className={styles.iconBtn}
onClick={cycleTheme}
aria-label={`Theme: ${themeLabel}. Click to change.`}
>
{themeIcon}
</button>
</Hint>
) : (
<div className={styles.themeGroup} role="radiogroup" aria-label="Theme">
{THEMES.map((t) => {
const on = theme === t.value
return (
<button
key={t.value}
type="button"
role="radio"
aria-checked={on}
className={mergeClasses(styles.themeSeg, on && styles.themeSegActive)}
onClick={() => onSetTheme(t.value)}
>
<span className={styles.navIcon}>{t.icon}</span>
{t.label}
</button>
)
})}
</div>
)}
</nav>
)
}
+5 -3
View File
@@ -11,7 +11,8 @@ import App from './App'
// In the real Electron app this branch is skipped — preload provides window.api.
if (import.meta.env.DEV && !window.api) {
const MOCK_SETTINGS: Settings = {
outputDir: 'C:\\Users\\you\\Downloads',
videoDir: 'C:\\Users\\you\\Documents\\Video',
audioDir: 'C:\\Users\\you\\Documents\\Audio',
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best (MP3)',
@@ -44,6 +45,7 @@ if (import.meta.env.DEV && !window.api) {
]
window.api = {
getAppVersion: async () => '0.3.2-preview',
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
probe: async (url: string) => {
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
@@ -121,11 +123,11 @@ if (import.meta.env.DEV && !window.api) {
previewCommand: async (opts) => {
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
const dir = opts.kind === 'audio' ? MOCK_SETTINGS.audioDir : MOCK_SETTINGS.videoDir
return {
ok: true,
command:
`yt-dlp.exe -f "bv*+ba/b" -o "${MOCK_SETTINGS.outputDir}\\%(title)s.%(ext)s"` +
`${extra} -- ${opts.url}`
`yt-dlp.exe -f "bv*+ba/b" -o "${dir}\\%(title)s.%(ext)s"` + `${extra} -- ${opts.url}`
}
},
updateYtdlp: async (channel) => {
+2 -1
View File
@@ -247,7 +247,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
url: item.url,
kind: item.kind,
quality: item.quality,
outputDir: useSettings.getState().outputDir || undefined,
// No outputDir here: main routes each download into the user's per-kind
// folder (Settings → Video/Audio folder), or the Documents\… default.
formatId: item.formatId,
formatHasAudio: item.formatHasAudio,
options: item.options,
+11 -5
View File
@@ -5,7 +5,8 @@ import { DEFAULT_DOWNLOAD_OPTIONS, type Settings } from '@shared/ipc'
const PREVIEW = typeof window === 'undefined' || !window.electron
const FALLBACK: Settings = {
outputDir: PREVIEW ? 'C:\\Users\\you\\Downloads' : '',
videoDir: PREVIEW ? 'C:\\Users\\you\\Documents\\Video' : '',
audioDir: PREVIEW ? 'C:\\Users\\you\\Documents\\Audio' : '',
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best (MP3)',
@@ -35,7 +36,10 @@ interface SettingsState extends Settings {
/** true once persisted settings have loaded (always true in preview) */
loaded: boolean
update: (partial: Partial<Settings>) => void
chooseOutputDir: () => void
/** open the OS folder picker and store the result as the video or audio folder */
chooseDir: (target: 'videoDir' | 'audioDir') => void
/** clear a per-kind folder override, restoring its Documents\… default */
clearDir: (target: 'videoDir' | 'audioDir') => void
}
export const useSettings = create<SettingsState>((set, get) => ({
@@ -47,12 +51,14 @@ export const useSettings = create<SettingsState>((set, get) => ({
if (!PREVIEW) window.api.setSettings(partial).catch(() => {})
},
chooseOutputDir: () => {
chooseDir: (target) => {
if (PREVIEW) return
window.api.chooseFolder().then((dir) => {
if (dir) get().update({ outputDir: dir })
if (dir) get().update({ [target]: dir })
})
}
},
clearDir: (target) => get().update({ [target]: '' })
}))
// Load persisted settings on startup.
+14
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import type { SystemThemeInfo } from '@shared/ipc'
import { useSettings } from './settings'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -24,3 +25,16 @@ if (!PREVIEW) {
useSystemTheme.setState({ shouldUseDarkColors: e.matches })
)
}
/**
* Whether the app is currently rendering in dark mode. Resolves the 'system'
* preference against the live OS signal, so a component never has to special-case
* it (the bug where a raw `theme === 'dark'` check left thumbnails light under
* Auto + a dark OS). The single source of truth for light/dark — used by App for
* the FluentProvider and by anything that needs theme-aware colors (thumbColors).
*/
export function useResolvedDark(): boolean {
const theme = useSettings((s) => s.theme)
const systemPrefersDark = useSystemTheme((s) => s.shouldUseDarkColors)
return theme === 'system' ? systemPrefersDark : theme === 'dark'
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Thumbnail helpers. A media "preview" thumbnail is either the explicit image URL
* yt-dlp handed us at probe time, or — for a YouTube video — one derived from the
* video id with no extra network probe. Anything non-YouTube without a probed
* thumbnail returns undefined, and the UI falls back to a kind icon.
*/
/**
* Pull the video id out of any YouTube watch / shorts / embed / live / youtu.be
* URL. Returns null for non-YouTube URLs or anything unparseable, so callers can
* cheaply ask "is there a thumbnail derivable from this link?".
*/
export function youtubeId(url: string | undefined): string | null {
if (!url) return null
let u: URL
try {
u = new URL(url)
} catch {
return null
}
const host = u.hostname.replace(/^www\./i, '').toLowerCase()
if (host === 'youtu.be') return u.pathname.slice(1).split('/')[0] || null
if (host === 'youtube.com' || host.endsWith('.youtube.com')) {
const v = u.searchParams.get('v')
if (v) return v
const m = u.pathname.match(/^\/(?:embed|shorts|v|live)\/([^/?#]+)/i)
if (m) return m[1]
}
return null
}
/**
* Resolve a preview thumbnail URL for a media item. Prefers an explicit thumbnail
* (from a probe), then a known videoId, then a YouTube URL it can derive an id
* from. Returns undefined when nothing usable is available.
*/
export function thumbUrl(opts: {
thumbnail?: string
videoId?: string
url?: string
}): string | undefined {
if (opts.thumbnail) return opts.thumbnail
const id = opts.videoId || youtubeId(opts.url)
// mqdefault is 320×180 (16:9, no letterbox bars) and exists for every public
// video — a better fit for our 16:9 thumb boxes than the 4:3 hqdefault.
return id ? `https://i.ytimg.com/vi/${encodeURIComponent(id)}/mqdefault.jpg` : undefined
}
+23 -4
View File
@@ -4,6 +4,8 @@
*/
export const IpcChannels = {
/** the AeroFetch app version (package.json / app.getVersion) */
appVersion: 'app:version',
ytdlpVersion: 'ytdlp:version',
probe: 'media:probe',
downloadStart: 'download:start',
@@ -186,8 +188,23 @@ export interface YtdlpVersionResult {
error?: string
}
/** yt-dlp's self-update release channel (`--update-to <channel>`). */
export type YtdlpUpdateChannel = 'stable' | 'nightly'
/**
* yt-dlp's self-update release channels (`--update-to <channel>`).
*
* SECURITY (audit F1): `--update-to` also accepts an `OWNER/REPO@TAG` spec, which
* makes yt-dlp download a release binary from an ARBITRARY GitHub repo and
* overwrite the running yt-dlp.exe — i.e. arbitrary, persistent code execution.
* The TypeScript type is erased at runtime and is no defence over IPC, so the
* value must be checked against this allowlist before it ever reaches the flag
* (see isYtdlpUpdateChannel; enforced in src/main/ytdlp.ts).
*/
export const YTDLP_UPDATE_CHANNELS = ['stable', 'nightly'] as const
export type YtdlpUpdateChannel = (typeof YTDLP_UPDATE_CHANNELS)[number]
/** Runtime guard for an untrusted update-channel value crossing the IPC boundary. */
export function isYtdlpUpdateChannel(v: unknown): v is YtdlpUpdateChannel {
return typeof v === 'string' && (YTDLP_UPDATE_CHANNELS as readonly string[]).includes(v)
}
export interface YtdlpUpdateResult {
ok: boolean
@@ -340,8 +357,10 @@ export type DownloadEvent =
/** Persisted user settings (electron-store). */
export interface Settings {
/** absolute output directory (empty string resolves to the OS Downloads folder) */
outputDir: string
/** where video downloads are saved; empty string = the default Documents\Video folder */
videoDir: string
/** where audio downloads are saved; empty string = the default Documents\Audio folder */
audioDir: string
defaultKind: MediaKind
defaultVideoQuality: string
defaultAudioQuality: string
+83
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import {
buildArgs,
parseExtraArgs,
selectExtraArgs,
formatCommandLine,
sanitizeDirSegment,
collectionOutputTemplate,
@@ -328,6 +329,80 @@ describe('parseExtraArgs', () => {
})
})
describe('selectExtraArgs — custom-command consent gate (audit F2)', () => {
const templates = [
{ id: 'thumb', args: '--write-thumbnail' },
{ id: 'danger', args: '--exec "calc.exe"' }
]
it('returns [] when custom commands are disabled, even with a per-download override', () => {
// The core fix: a renderer-supplied extraArgs must NOT run while the gate is off.
expect(
selectExtraArgs({
customCommandEnabled: false,
perDownloadExtraArgs: '--exec "calc.exe"',
defaultTemplateId: 'danger',
templates
})
).toEqual([])
})
it('returns [] when disabled even if a default template id is set', () => {
expect(
selectExtraArgs({
customCommandEnabled: false,
perDownloadExtraArgs: undefined,
defaultTemplateId: 'thumb',
templates
})
).toEqual([])
})
it('parses a per-download override when enabled', () => {
expect(
selectExtraArgs({
customCommandEnabled: true,
perDownloadExtraArgs: '--write-thumbnail --no-mtime',
defaultTemplateId: null,
templates
})
).toEqual(['--write-thumbnail', '--no-mtime'])
})
it('an explicit empty override yields [] even with a default template set', () => {
expect(
selectExtraArgs({
customCommandEnabled: true,
perDownloadExtraArgs: '',
defaultTemplateId: 'thumb',
templates
})
).toEqual([])
})
it('falls back to the default template when no per-download override is given', () => {
expect(
selectExtraArgs({
customCommandEnabled: true,
perDownloadExtraArgs: undefined,
defaultTemplateId: 'thumb',
templates
})
).toEqual(['--write-thumbnail'])
})
it('returns [] when the default template id matches nothing', () => {
expect(
selectExtraArgs({
customCommandEnabled: true,
perDownloadExtraArgs: undefined,
defaultTemplateId: 'missing',
templates
})
).toEqual([])
})
})
describe('formatCommandLine', () => {
it('joins the exe and args with spaces when nothing needs quoting', () => {
expect(formatCommandLine('yt-dlp.exe', ['-f', 'best', '--', 'https://x.test/v'])).toBe(
@@ -410,6 +485,14 @@ describe('sanitizeDirSegment', () => {
expect(sanitizeDirSegment('com1')).toBe('_com1')
})
it('prefixes a reserved device name even when it carries an extension (audit T3)', () => {
expect(sanitizeDirSegment('CON.txt')).toBe('_CON.txt')
expect(sanitizeDirSegment('nul.mp4')).toBe('_nul.mp4')
expect(sanitizeDirSegment('LPT1.foo.bar')).toBe('_LPT1.foo.bar')
// a non-reserved name that merely starts with similar letters is left alone
expect(sanitizeDirSegment('console.log')).toBe('console.log')
})
it('falls back to Untitled for empty / all-illegal input', () => {
expect(sanitizeDirSegment('')).toBe('Untitled')
expect(sanitizeDirSegment('???')).toBe('Untitled')
+26
View File
@@ -42,6 +42,20 @@ describe('extractIncomingUrl — aerofetch:// protocol', () => {
it('returns null when there is no url= param', () => {
expect(extractIncomingUrl(['AeroFetch.exe', 'aerofetch://download'])).toBeNull()
})
it('normalises the target, stripping embedded control chars (audit T3 / F5)', () => {
// A tab spliced into the inner URL must not survive to the renderer banner.
const raw = 'https://www.youtube.com/watch?v=abc\tdef'
const arg = `aerofetch://download?url=${encodeURIComponent(raw)}`
expect(extractIncomingUrl(['AeroFetch.exe', arg])).toBe(
'https://www.youtube.com/watch?v=abcdef'
)
})
it('matches the aerofetch:// scheme case-insensitively (audit T5)', () => {
const arg = `AEROFETCH://download?url=${encodeURIComponent(TARGET)}`
expect(extractIncomingUrl(['AeroFetch.exe', arg])).toBe(TARGET)
})
})
describe('extractIncomingUrl — .url Internet Shortcut (Explorer "Send to")', () => {
@@ -62,6 +76,18 @@ describe('extractIncomingUrl — .url Internet Shortcut (Explorer "Send to")', (
it('ignores files that merely end in .url-like text but are not real paths', () => {
expect(extractIncomingUrl(['AeroFetch.exe', 'not-a-real-path.url'])).toBeNull()
})
it('reads a URL= line that falls within the 64 KB size cap (audit T5)', () => {
const junk = '; padding\r\n'.repeat(100) // ~1 KB of leading content
const path = urlFile('Small.url', `[InternetShortcut]\r\n${junk}URL=${TARGET}\r\n`)
expect(extractIncomingUrl(['AeroFetch.exe', path])).toBe(TARGET)
})
it('ignores a URL= line that falls past the 64 KB size cap (audit T5)', () => {
const junk = '; padding\r\n'.repeat(8000) // ~88 KB, beyond the read window
const path = urlFile('Huge.url', `[InternetShortcut]\r\n${junk}URL=${TARGET}\r\n`)
expect(extractIncomingUrl(['AeroFetch.exe', path])).toBeNull()
})
})
describe('extractIncomingUrl — no match', () => {
+25
View File
@@ -4,6 +4,7 @@ import {
buildMediaItems,
mergeItemsPreservingState,
buildFeedUrl,
isYouTubeFeedUrl,
parseRssVideoIds,
entryUrl,
fmtDuration,
@@ -57,6 +58,10 @@ describe('entryUrl', () => {
expect(entryUrl({ url: 'javascript:alert(1)' })).toBeNull()
expect(entryUrl({})).toBeNull()
})
it('percent-encodes an untrusted id rather than splicing it raw (audit T2)', () => {
expect(entryUrl({ id: 'ab cd&x=1' })).toBe('https://www.youtube.com/watch?v=ab%20cd%26x%3D1')
expect(entryUrl({ id: 'vid123' })).toBe('https://www.youtube.com/watch?v=vid123') // unchanged
})
})
describe('fmtDuration', () => {
@@ -189,6 +194,26 @@ describe('buildFeedUrl', () => {
})
})
describe('isYouTubeFeedUrl (audit T7 — SSRF guard)', () => {
it('accepts a youtube feeds URL (www optional) and what buildFeedUrl produces', () => {
expect(isYouTubeFeedUrl('https://www.youtube.com/feeds/videos.xml?channel_id=UCabc')).toBe(true)
expect(isYouTubeFeedUrl('https://youtube.com/feeds/videos.xml?playlist_id=PLxyz')).toBe(true)
expect(isYouTubeFeedUrl(buildFeedUrl('channel', 'UCabc')!)).toBe(true)
expect(isYouTubeFeedUrl(buildFeedUrl('playlist', 'PLxyz')!)).toBe(true)
})
it('rejects internal/arbitrary hosts, wrong scheme, and wrong path (SSRF vectors)', () => {
expect(isYouTubeFeedUrl('http://169.254.169.254/latest/meta-data/')).toBe(false) // cloud metadata
expect(isYouTubeFeedUrl('http://localhost:8080/admin')).toBe(false)
expect(isYouTubeFeedUrl('https://evil.com/feeds/videos.xml')).toBe(false)
expect(isYouTubeFeedUrl('https://notyoutube.com.evil.com/feeds/videos.xml')).toBe(false)
expect(isYouTubeFeedUrl('http://www.youtube.com/feeds/videos.xml')).toBe(false) // not https
expect(isYouTubeFeedUrl('https://www.youtube.com/watch?v=x')).toBe(false) // wrong path
expect(isYouTubeFeedUrl('file:///etc/passwd')).toBe(false)
expect(isYouTubeFeedUrl('not a url')).toBe(false)
})
})
describe('parseRssVideoIds', () => {
it('extracts every <yt:videoId> from a feed body, in order', () => {
const xml = `
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest'
import { assertHttpUrl } from '../src/main/url'
import { isAllowedLoginUrl } from '../src/main/cookies'
import { isYtdlpUpdateChannel } from '@shared/ipc'
// --- F5: assertHttpUrl — argument-injection guard + normalisation -----------
describe('assertHttpUrl (audit F5)', () => {
it('accepts http(s) URLs and returns the normalised href', () => {
expect(assertHttpUrl('https://www.youtube.com/watch?v=abc')).toBe(
'https://www.youtube.com/watch?v=abc'
)
// http with a bare host normalises to a trailing slash
expect(assertHttpUrl('http://example.com')).toBe('http://example.com/')
})
it('trims surrounding whitespace', () => {
expect(assertHttpUrl(' https://example.com/v ')).toBe('https://example.com/v')
})
it('strips interior tabs/newlines the URL parser tolerates (normalised output)', () => {
// Returning the raw input would leak these through to argv / loadURL.
expect(assertHttpUrl('https://exa\nmple.com/v')).toBe('https://example.com/v')
const out = assertHttpUrl('https://example.com/a\tb')
expect(out).not.toContain('\t')
expect(out).not.toContain('\n')
})
it('rejects non-http(s) protocols', () => {
expect(() => assertHttpUrl('file:///C:/Windows/system32')).toThrow()
expect(() => assertHttpUrl('javascript:alert(1)')).toThrow()
expect(() => assertHttpUrl('ftp://example.com/x')).toThrow()
expect(() => assertHttpUrl('aerofetch://download?url=x')).toThrow()
})
it('rejects unparseable input', () => {
expect(() => assertHttpUrl('not a url')).toThrow()
expect(() => assertHttpUrl('')).toThrow()
})
it('can never return a value that begins with "-" (would read as a yt-dlp flag)', () => {
// A leading '-' cannot start a valid URL scheme, so it always throws —
// the returned value therefore never opens with a dash.
expect(() => assertHttpUrl('-https://example.com')).toThrow()
})
})
// --- T4: isAllowedLoginUrl — sign-in window navigation confinement ----------
describe('isAllowedLoginUrl (audit T4)', () => {
it('allows http(s) and about:blank', () => {
expect(isAllowedLoginUrl('https://accounts.google.com/signin')).toBe(true)
expect(isAllowedLoginUrl('http://example.com/login')).toBe(true)
expect(isAllowedLoginUrl('about:blank')).toBe(true)
})
it('blocks file://, the app protocol, and other external URI schemes', () => {
expect(isAllowedLoginUrl('file:///C:/Windows/System32/calc.exe')).toBe(false)
expect(isAllowedLoginUrl('aerofetch://download?url=https://evil.test')).toBe(false)
expect(isAllowedLoginUrl('ms-settings:')).toBe(false)
expect(isAllowedLoginUrl('mailto:x@y.z')).toBe(false)
expect(isAllowedLoginUrl('javascript:alert(1)')).toBe(false)
})
it('blocks unparseable input', () => {
expect(isAllowedLoginUrl('not a url')).toBe(false)
expect(isAllowedLoginUrl('')).toBe(false)
})
})
// --- F1: isYtdlpUpdateChannel — --update-to allowlist -----------------------
describe('isYtdlpUpdateChannel (audit F1)', () => {
it('accepts the two supported channels', () => {
expect(isYtdlpUpdateChannel('stable')).toBe(true)
expect(isYtdlpUpdateChannel('nightly')).toBe(true)
})
it('rejects an arbitrary repository spec (the --update-to RCE vector)', () => {
expect(isYtdlpUpdateChannel('evil/yt-dlp@latest')).toBe(false)
expect(isYtdlpUpdateChannel('owner/repo')).toBe(false)
})
it('rejects other yt-dlp channels not on AeroFetchs allowlist', () => {
expect(isYtdlpUpdateChannel('master')).toBe(false)
})
it('rejects non-string / empty values', () => {
expect(isYtdlpUpdateChannel('')).toBe(false)
expect(isYtdlpUpdateChannel(undefined)).toBe(false)
expect(isYtdlpUpdateChannel(null)).toBe(false)
expect(isYtdlpUpdateChannel(42)).toBe(false)
})
})
+66 -2
View File
@@ -4,9 +4,10 @@ import {
isSafeOutputDir,
isValidHistoryEntry,
isValidErrorLogEntry,
isTemplateLike
isTemplateLike,
isValidSource
} from '../src/main/validation'
import type { HistoryEntry, ErrorLogEntry } from '@shared/ipc'
import type { HistoryEntry, ErrorLogEntry, Source } from '@shared/ipc'
// --- S4: filename template path-traversal -----------------------------------
@@ -40,6 +41,24 @@ describe('isSafeFilenameTemplate', () => {
it('allows .. only when embedded in a longer segment (not a real traversal)', () => {
// '..foo' / 'foo..bar' are filenames, not parent-dir references.
expect(isSafeFilenameTemplate('my..video.%(ext)s')).toBe(true)
expect(isSafeFilenameTemplate('.%(title)s.%(ext)s')).toBe(true) // leading dot = hidden file
})
// --- T1: Windows-specific bypasses --------------------------------------
it('rejects a drive-relative prefix that path.isAbsolute misses', () => {
// 'C:foo' is NOT absolute per Node, but still escapes the output dir.
expect(isSafeFilenameTemplate('C:foo\\%(title)s.%(ext)s')).toBe(false)
expect(isSafeFilenameTemplate('c:%(title)s')).toBe(false)
})
it('rejects a .. segment dressed up with trailing/leading dots or spaces', () => {
// Windows trims trailing dots/spaces, so these all resolve to '..'.
expect(isSafeFilenameTemplate('%(title)s/.. /x')).toBe(false)
expect(isSafeFilenameTemplate('%(title)s/.. ./x')).toBe(false)
expect(isSafeFilenameTemplate('%(title)s/ ../x')).toBe(false)
expect(isSafeFilenameTemplate('%(title)s\\.. \\x')).toBe(false)
expect(isSafeFilenameTemplate('...')).toBe(false)
})
})
@@ -144,3 +163,48 @@ describe('isTemplateLike', () => {
expect(isTemplateLike('str')).toBe(false)
})
})
// --- T7: source row validation (feedUrl drives a network fetch) --------------
const validSource: Source = {
id: 's1',
url: 'https://www.youtube.com/@x',
kind: 'channel',
title: 'X',
addedAt: 1700000000000,
itemCount: 5
}
describe('isValidSource', () => {
it('accepts a well-formed source and its optional fields', () => {
expect(isValidSource(validSource)).toBe(true)
expect(
isValidSource({
...validSource,
channel: 'X',
watched: true,
feedUrl: 'https://www.youtube.com/feeds/videos.xml?channel_id=UC',
lastIndexedAt: 1700000000001
})
).toBe(true)
})
it('rejects a non-string feedUrl (audit T7)', () => {
expect(isValidSource({ ...validSource, feedUrl: 123 })).toBe(false)
})
it('rejects a non-boolean watched (audit T7)', () => {
expect(isValidSource({ ...validSource, watched: 'yes' })).toBe(false)
})
it('rejects a wrong kind or a missing required field', () => {
expect(isValidSource({ ...validSource, kind: 'video' })).toBe(false)
const { id: _id, ...noId } = validSource
expect(isValidSource(noId)).toBe(false)
})
it('rejects non-objects', () => {
expect(isValidSource(null)).toBe(false)
expect(isValidSource('nope')).toBe(false)
})
})