Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa699a803d | |||
| 97e725c774 | |||
| fa92383ef4 | |||
| 7134a3d634 | |||
| 981d2dd34d | |||
| 08b9ed9e7a | |||
| 5e3512f366 | |||
| e8ab8b9c73 | |||
| 76098c6928 | |||
| 3536626a8a | |||
| 2718624828 | |||
| 37687f9870 | |||
| a2763f10b4 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aerofetch",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.1",
|
||||
"description": "A yt-dlp frontend for Windows",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "AeroFetch",
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
+42
-1
@@ -11,9 +11,10 @@ import {
|
||||
type SystemThemeInfo
|
||||
} from '@shared/ipc'
|
||||
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
|
||||
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
|
||||
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 +91,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 +163,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 +180,14 @@ function createWindow(): void {
|
||||
}
|
||||
|
||||
function registerIpcHandlers(): void {
|
||||
ipcMain.handle(IpcChannels.appVersion, () => app.getVersion())
|
||||
|
||||
ipcMain.handle(IpcChannels.appUpdateCheck, () => checkForAppUpdate())
|
||||
ipcMain.handle(IpcChannels.appUpdateDownload, (e, url: string) =>
|
||||
downloadAppUpdate(url, e.sender)
|
||||
)
|
||||
ipcMain.handle(IpcChannels.appUpdateRun, (_e, filePath: string) => runAppUpdate(filePath))
|
||||
|
||||
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
|
||||
|
||||
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
|
||||
@@ -303,6 +340,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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import { app, net, shell, type WebContents } from 'electron'
|
||||
import { createWriteStream, type WriteStream } from 'fs'
|
||||
import { stat, unlink } from 'fs/promises'
|
||||
import { join, normalize, dirname } from 'path'
|
||||
import { createHash } from 'crypto'
|
||||
import {
|
||||
IpcChannels,
|
||||
type AppUpdateInfo,
|
||||
type AppUpdateDownload,
|
||||
type AppUpdateProgress
|
||||
} from '@shared/ipc'
|
||||
|
||||
// --- Update source -----------------------------------------------------------
|
||||
// The Gitea repo whose Releases host the AeroFetch installers. The updater reads
|
||||
// the repo's latest release over the public REST API and downloads the installer
|
||||
// asset attached to it.
|
||||
//
|
||||
// IMPORTANT: this points at a repo whose releases must be ANONYMOUSLY readable —
|
||||
// i.e. a public repo on a Gitea instance that permits anonymous API + downloads.
|
||||
// AeroFetch never ships a token; on a sign-in-required instance the check simply
|
||||
// reports that it couldn't reach the server. Change OWNER/REPO to retarget.
|
||||
const UPDATE_HOST = 'https://gitea.netbird.zimspace.uk:5938'
|
||||
const UPDATE_OWNER = 'debont80'
|
||||
const UPDATE_REPO = 'AeroFetch'
|
||||
const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`
|
||||
|
||||
// Security: only ever download or execute a file served by the trusted update
|
||||
// host over HTTPS. downloadUrl comes from the release JSON, and Gitea 302-redirects
|
||||
// release downloads to its attachment store — so this is re-checked on EVERY
|
||||
// redirect hop (see downloadAppUpdate), which is what stops a tampered/MITM'd
|
||||
// response from bouncing us off the trusted host.
|
||||
export function isTrustedDownloadUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return u.protocol === 'https:' && u.host === new URL(UPDATE_HOST).host
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Where downloaded installers are written (and the only dir we'll run one from). */
|
||||
function updateDir(): string {
|
||||
return app.getPath('temp')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse 'v1.2.3' / '1.2.3-rc.1' into the comparable numeric components of the
|
||||
* release CORE — the x.y.z before any '-prerelease' or '+build' suffix. Dropping
|
||||
* the suffix means a prerelease (e.g. 0.5.0-rc.1) never sorts ABOVE its final
|
||||
* release (0.5.0): at most it compares equal, so we won't offer a prerelease as
|
||||
* an "upgrade" over the same shipped version.
|
||||
*/
|
||||
function parseVersion(v: string): number[] {
|
||||
return v
|
||||
.replace(/^v/i, '')
|
||||
.split(/[-+]/)[0]
|
||||
.split('.')
|
||||
.map((p) => parseInt(p, 10))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
}
|
||||
|
||||
/** Component-wise numeric compare: -1 if a<b, 0 if equal, 1 if a>b. */
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const pa = parseVersion(a)
|
||||
const pb = parseVersion(b)
|
||||
const len = Math.max(pa.length, pb.length)
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = pa[i] ?? 0
|
||||
const y = pb[i] ?? 0
|
||||
if (x !== y) return x < y ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
interface GiteaAsset {
|
||||
name: string
|
||||
browser_download_url: string
|
||||
}
|
||||
interface GiteaRelease {
|
||||
tag_name: string
|
||||
body?: string
|
||||
html_url?: string
|
||||
assets?: GiteaAsset[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the SHA-256 hex out of a checksum file — a bare digest, `sha256sum`
|
||||
* output (`<hash> file`), or PowerShell Get-FileHash (uppercase). Returns the
|
||||
* lowercase digest, or null if there's no standalone 64-char hex token (so a
|
||||
* longer run like a sha512 digest is ignored rather than sliced).
|
||||
*/
|
||||
export function extractSha256(text: string): string | null {
|
||||
const m = text.match(/\b[a-f0-9]{64}\b/i)
|
||||
return m ? m[0].toLowerCase() : null
|
||||
}
|
||||
|
||||
/** Pick the Windows installer asset — prefer the NSIS "Setup" .exe, else any .exe. */
|
||||
function pickInstaller(assets: GiteaAsset[]): GiteaAsset | undefined {
|
||||
const exes = assets.filter((a) => /\.exe$/i.test(a.name))
|
||||
return exes.find((a) => /setup/i.test(a.name)) ?? exes[0]
|
||||
}
|
||||
|
||||
/** Query the configured repo's latest release and compare it to the running app. */
|
||||
export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
|
||||
const currentVersion = app.getVersion()
|
||||
// Bound the check so a stalled/unreachable server can't hang the UI spinner
|
||||
// indefinitely — mirrors the timeouts on the yt-dlp calls.
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), 15_000)
|
||||
try {
|
||||
const res = await fetch(RELEASE_API, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: controller.signal
|
||||
})
|
||||
if (!res.ok) {
|
||||
const auth = res.status === 401 || res.status === 403 || res.status === 404
|
||||
return {
|
||||
ok: false,
|
||||
available: false,
|
||||
currentVersion,
|
||||
error: auth
|
||||
? 'Could not reach the update server (it may require sign-in for anonymous access).'
|
||||
: `Update check failed (HTTP ${res.status}).`
|
||||
}
|
||||
}
|
||||
const rel = (await res.json()) as GiteaRelease
|
||||
const latestVersion = (rel.tag_name ?? '').replace(/^v/i, '')
|
||||
const asset = pickInstaller(rel.assets ?? [])
|
||||
const available = latestVersion !== '' && compareVersions(latestVersion, currentVersion) > 0
|
||||
return {
|
||||
ok: true,
|
||||
available,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
notes: rel.body?.trim() || undefined,
|
||||
// Only ever hand the OS browser a release page on our own trusted host.
|
||||
htmlUrl: rel.html_url && isTrustedDownloadUrl(rel.html_url) ? rel.html_url : undefined,
|
||||
downloadUrl: asset?.browser_download_url,
|
||||
assetName: asset?.name
|
||||
}
|
||||
} catch (e) {
|
||||
const timedOut = e instanceof Error && e.name === 'AbortError'
|
||||
return {
|
||||
ok: false,
|
||||
available: false,
|
||||
currentVersion,
|
||||
error: timedOut
|
||||
? 'Update check timed out — the server took too long to respond.'
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: String(e)
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/** How long the download may stall (no bytes received) before we give up. */
|
||||
const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000
|
||||
|
||||
// Integrity: every release MUST attach a checksum asset named `<installer>.sha256`
|
||||
// (e.g. AeroFetch-Setup-0.5.0.exe.sha256) whose contents include the installer's
|
||||
// lowercase SHA-256 hex — bare, or in `sha256sum`/`Get-FileHash` form. We refuse
|
||||
// to run an installer we can't verify against it. Note this is INTEGRITY +
|
||||
// defence-in-depth (corruption, truncation, tampering-after-publish, a redirect
|
||||
// bounced to other storage), NOT protection against a fully compromised host —
|
||||
// that host serves both files, so only Authenticode signing defends against it.
|
||||
const REQUIRE_CHECKSUM: boolean = true
|
||||
|
||||
/**
|
||||
* GET a small text resource from the trusted host, re-validating the host on
|
||||
* every redirect hop (same discipline as the installer download) and capping the
|
||||
* body so a hostile/huge response can't exhaust memory. Used for the .sha256 file.
|
||||
*/
|
||||
function fetchTrustedText(
|
||||
url: string,
|
||||
maxBytes = 64 * 1024,
|
||||
timeoutMs = 15_000
|
||||
): Promise<{ ok: true; text: string } | { ok: false; status?: number; error: string }> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const done = (r: { ok: true; text: string } | { ok: false; status?: number; error: string }): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
if (!r.ok) request.abort()
|
||||
resolve(r)
|
||||
}
|
||||
const request = net.request({ url, redirect: 'manual' })
|
||||
const timer = setTimeout(() => done({ ok: false, error: 'timed out' }), timeoutMs)
|
||||
request.on('redirect', (_s, _m, redirectUrl) => {
|
||||
if (!isTrustedDownloadUrl(redirectUrl)) {
|
||||
done({ ok: false, error: 'redirect to an untrusted location' })
|
||||
return
|
||||
}
|
||||
request.followRedirect()
|
||||
})
|
||||
request.on('response', (response) => {
|
||||
const status = response.statusCode
|
||||
if (status < 200 || status >= 300) {
|
||||
done({ ok: false, status, error: `HTTP ${status}` })
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
let size = 0
|
||||
response.on('data', (c: Buffer) => {
|
||||
size += c.length
|
||||
if (size > maxBytes) done({ ok: false, error: 'checksum file too large' })
|
||||
else chunks.push(c)
|
||||
})
|
||||
response.on('end', () => done({ ok: true, text: Buffer.concat(chunks).toString('utf8') }))
|
||||
response.on('aborted', () => done({ ok: false, error: 'interrupted' }))
|
||||
response.on('error', (e: Error) => done({ ok: false, error: e.message }))
|
||||
})
|
||||
request.on('error', (e: Error) => done({ ok: false, error: e.message }))
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
|
||||
/** Stream the installer to a temp file, pushing progress events to the renderer. */
|
||||
export async function downloadAppUpdate(url: string, wc: WebContents): Promise<AppUpdateDownload> {
|
||||
if (!isTrustedDownloadUrl(url)) {
|
||||
return { ok: false, error: 'Refused to download from an untrusted location.' }
|
||||
}
|
||||
|
||||
// Derive a safe .exe filename from the URL; fall back to a fixed name.
|
||||
const urlName = decodeURIComponent(new URL(url).pathname.split('/').pop() || '')
|
||||
const safeName = /^[\w.\- ]+\.exe$/i.test(urlName) ? urlName : 'AeroFetch-Setup.exe'
|
||||
const filePath = join(updateDir(), safeName)
|
||||
|
||||
// Resolve the published checksum up front (tiny, fails fast) so we never pull a
|
||||
// ~300 MB installer we'd then refuse to run. The .sha256 sits next to the asset,
|
||||
// so its URL is the installer URL + '.sha256' — still on the host-pinned origin.
|
||||
const checksumUrl = (() => {
|
||||
const u = new URL(url)
|
||||
u.pathname += '.sha256'
|
||||
return u.toString()
|
||||
})()
|
||||
const sum = await fetchTrustedText(checksumUrl)
|
||||
let expectedSha: string | null = null
|
||||
if (sum.ok) {
|
||||
expectedSha = extractSha256(sum.text)
|
||||
if (!expectedSha) return { ok: false, error: "The update's checksum file is malformed." }
|
||||
} else if (sum.status === 404) {
|
||||
if (REQUIRE_CHECKSUM) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `This release has no checksum (${safeName}.sha256) attached — refusing to install an unverified update.`
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return { ok: false, error: `Couldn't fetch the update's checksum — ${sum.error}.` }
|
||||
}
|
||||
|
||||
return new Promise<AppUpdateDownload>((resolve) => {
|
||||
let settled = false
|
||||
let fileStream: WriteStream | null = null
|
||||
let idle: NodeJS.Timeout | null = null
|
||||
|
||||
// Single teardown point. On failure it also aborts the request, so a write
|
||||
// error (disk full, etc.) can't leave Electron pulling bytes into a dead
|
||||
// stream. abort() is idempotent, so the call sites below don't repeat it.
|
||||
const finish = (result: AppUpdateDownload): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (idle) clearTimeout(idle)
|
||||
if (!result.ok) {
|
||||
request.abort()
|
||||
// Close the handle before unlinking (Windows won't delete an open file)
|
||||
// and never leave a partial/aborted installer lying around in temp.
|
||||
if (fileStream && !fileStream.destroyed) fileStream.destroy()
|
||||
unlink(filePath).catch(() => {})
|
||||
}
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
// net.request (not fetch) so we can re-validate the host on EVERY redirect
|
||||
// hop: Gitea 302s a release download to its attachment store, and undici's
|
||||
// fetch hides the Location header under redirect:'manual', so it can't gate
|
||||
// hops. 'manual' here means a hop only proceeds if we call followRedirect().
|
||||
const request = net.request({ url, redirect: 'manual' })
|
||||
|
||||
const armIdle = (): void => {
|
||||
if (idle) clearTimeout(idle)
|
||||
idle = setTimeout(() => {
|
||||
finish({ ok: false, error: 'Download stalled — please try again.' })
|
||||
}, DOWNLOAD_IDLE_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
request.on('redirect', (_status, _method, redirectUrl) => {
|
||||
if (!isTrustedDownloadUrl(redirectUrl)) {
|
||||
finish({ ok: false, error: 'Refused to follow a redirect to an untrusted location.' })
|
||||
return
|
||||
}
|
||||
request.followRedirect()
|
||||
})
|
||||
|
||||
request.on('response', (response) => {
|
||||
const status = response.statusCode
|
||||
if (status < 200 || status >= 300) {
|
||||
finish({ ok: false, error: `Download failed (HTTP ${status}).` })
|
||||
return
|
||||
}
|
||||
|
||||
const lenHeader = response.headers['content-length']
|
||||
const total = Number(Array.isArray(lenHeader) ? lenHeader[0] : lenHeader) || undefined
|
||||
|
||||
const stream = createWriteStream(filePath)
|
||||
fileStream = stream
|
||||
// Hash the bytes as they stream by, so verification needs no second pass.
|
||||
const hash = createHash('sha256')
|
||||
// Electron's IncomingMessage is event-based (no .pipe). pause()/resume()
|
||||
// exist at runtime but aren't in its type, so feature-detect them to apply
|
||||
// backpressure — without it a ~300 MB installer can buffer in memory.
|
||||
const flow = response as unknown as { pause?(): void; resume?(): void }
|
||||
let received = 0
|
||||
armIdle()
|
||||
|
||||
response.on('data', (chunk: Buffer) => {
|
||||
armIdle()
|
||||
received += chunk.length
|
||||
hash.update(chunk)
|
||||
if (!stream.write(chunk) && flow.pause && flow.resume) {
|
||||
flow.pause()
|
||||
stream.once('drain', () => flow.resume?.())
|
||||
}
|
||||
if (!wc.isDestroyed()) {
|
||||
const progress: AppUpdateProgress = {
|
||||
received,
|
||||
total,
|
||||
fraction: total ? received / total : undefined
|
||||
}
|
||||
wc.send(IpcChannels.appUpdateProgress, progress)
|
||||
}
|
||||
})
|
||||
|
||||
response.on('end', () => {
|
||||
// Body fully received — "stalled" is no longer meaningful past this point.
|
||||
if (idle) clearTimeout(idle)
|
||||
stream.end(() => {
|
||||
// A truncated download (connection dropped mid-stream) must never be run.
|
||||
if (total !== undefined && received !== total) {
|
||||
finish({ ok: false, error: 'The download was incomplete — please try again.' })
|
||||
return
|
||||
}
|
||||
// Verify the bytes we wrote match the release's published SHA-256.
|
||||
if (expectedSha && hash.digest('hex') !== expectedSha) {
|
||||
finish({
|
||||
ok: false,
|
||||
error:
|
||||
'The downloaded update failed its checksum check — it may be corrupt or tampered with. Nothing was installed.'
|
||||
})
|
||||
return
|
||||
}
|
||||
finish({ ok: true, filePath })
|
||||
})
|
||||
})
|
||||
|
||||
// A mid-stream abort emits neither 'end' nor always 'error'; catch it so the
|
||||
// promise can't hang.
|
||||
response.on('aborted', () => finish({ ok: false, error: 'The download was interrupted.' }))
|
||||
response.on('error', (e: Error) => finish({ ok: false, error: e.message }))
|
||||
stream.on('error', (e: Error) => finish({ ok: false, error: e.message }))
|
||||
})
|
||||
|
||||
request.on('error', (e: Error) => finish({ ok: false, error: e.message }))
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
|
||||
/** Let the launched installer spawn before we quit to release our files (ms). */
|
||||
const INSTALLER_HANDOFF_MS = 1500
|
||||
|
||||
/** Launch a freshly-downloaded installer, then quit so it can replace the app. */
|
||||
export async function runAppUpdate(filePath: string): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
// Defence in depth: only run a .exe that sits DIRECTLY in our own temp dir,
|
||||
// never an arbitrary path handed over IPC. Compare the parent directory by
|
||||
// equality — startsWith() is defeated by a sibling like `<temp>_evil\x.exe`.
|
||||
const expectedDir = normalize(updateDir()).toLowerCase()
|
||||
const target = normalize(filePath)
|
||||
if (dirname(target).toLowerCase() !== expectedDir || !/\.exe$/i.test(target)) {
|
||||
return { ok: false, error: 'Refused to run an unexpected file.' }
|
||||
}
|
||||
await stat(target) // throws if the file is missing
|
||||
const err = await shell.openPath(target) // hand the installer to the OS
|
||||
if (err) return { ok: false, error: err }
|
||||
// Give the installer a beat to spawn, then quit so it can overwrite our files.
|
||||
setTimeout(() => app.quit(), INSTALLER_HANDOFF_MS)
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -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 doesn’t look like a valid URL.')
|
||||
}
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||
throw new Error('Only http and https links are supported.')
|
||||
}
|
||||
return trimmed
|
||||
return u.href
|
||||
}
|
||||
|
||||
+17
-7
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||
import {
|
||||
IpcChannels,
|
||||
type AppUpdateInfo,
|
||||
type AppUpdateDownload,
|
||||
type AppUpdateProgress,
|
||||
type YtdlpVersionResult,
|
||||
type YtdlpUpdateChannel,
|
||||
type YtdlpUpdateResult,
|
||||
@@ -28,6 +31,27 @@ 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),
|
||||
|
||||
/** Check the configured Gitea repo for a newer AeroFetch release. */
|
||||
checkForAppUpdate: (): Promise<AppUpdateInfo> => ipcRenderer.invoke(IpcChannels.appUpdateCheck),
|
||||
|
||||
/** Download the latest release's installer to a temp file. */
|
||||
downloadAppUpdate: (url: string): Promise<AppUpdateDownload> =>
|
||||
ipcRenderer.invoke(IpcChannels.appUpdateDownload, url),
|
||||
|
||||
/** Launch a downloaded installer and quit so it can replace the app. */
|
||||
runAppUpdate: (filePath: string): Promise<{ ok: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke(IpcChannels.appUpdateRun, filePath),
|
||||
|
||||
/** Subscribe to installer download progress. Returns an unsubscribe function. */
|
||||
onAppUpdateProgress: (cb: (p: AppUpdateProgress) => void): (() => void) => {
|
||||
const listener = (_e: IpcRendererEvent, p: AppUpdateProgress): void => cb(p)
|
||||
ipcRenderer.on(IpcChannels.appUpdateProgress, listener)
|
||||
return () => ipcRenderer.removeListener(IpcChannels.appUpdateProgress, listener)
|
||||
},
|
||||
|
||||
getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
|
||||
ipcRenderer.invoke(IpcChannels.ytdlpVersion),
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 — won’t 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Caption1,
|
||||
Spinner,
|
||||
Text,
|
||||
ProgressBar,
|
||||
makeStyles,
|
||||
mergeClasses,
|
||||
tokens,
|
||||
@@ -31,13 +32,16 @@ import {
|
||||
CopyRegular,
|
||||
DeleteRegular,
|
||||
PaintBucketRegular,
|
||||
AccessibilityRegular
|
||||
AccessibilityRegular,
|
||||
ArrowSyncRegular,
|
||||
OpenRegular
|
||||
} from '@fluentui/react-icons'
|
||||
import {
|
||||
COOKIE_BROWSERS,
|
||||
type YtdlpVersionResult,
|
||||
type YtdlpUpdateChannel,
|
||||
type YtdlpUpdateResult,
|
||||
type AppUpdateInfo,
|
||||
type MediaKind,
|
||||
type CookieSource,
|
||||
type CookieBrowser,
|
||||
@@ -71,6 +75,17 @@ const useStyles = makeStyles({
|
||||
padding: '20px',
|
||||
...shorthands.borderRadius(tokens.borderRadiusXLarge)
|
||||
},
|
||||
notesBox: {
|
||||
maxHeight: '180px',
|
||||
overflowY: 'auto',
|
||||
padding: '10px 12px',
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
lineHeight: tokens.lineHeightBase300
|
||||
},
|
||||
sectionHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -170,8 +185,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)
|
||||
@@ -207,6 +224,14 @@ export function SettingsView(): React.JSX.Element {
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
|
||||
|
||||
// --- AeroFetch app self-update ---
|
||||
const [appVersion, setAppVersion] = useState('')
|
||||
const [appUpd, setAppUpd] = useState<AppUpdateInfo | null>(null)
|
||||
const [appChecking, setAppChecking] = useState(false)
|
||||
const [appDownloading, setAppDownloading] = useState(false)
|
||||
const [appFraction, setAppFraction] = useState<number | undefined>(undefined)
|
||||
const [appUpdError, setAppUpdError] = useState<string | null>(null)
|
||||
|
||||
const [cookiesStatus, setCookiesStatus] = useState<CookiesStatus | null>(null)
|
||||
const [loginUrl, setLoginUrl] = useState('https://www.youtube.com')
|
||||
const [signingIn, setSigningIn] = useState(false)
|
||||
@@ -221,6 +246,49 @@ export function SettingsView(): React.JSX.Element {
|
||||
window.api.cookiesStatus().then(setCookiesStatus)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
window.api.getAppVersion().then(setAppVersion).catch(() => {})
|
||||
return window.api.onAppUpdateProgress((p) => setAppFraction(p.fraction))
|
||||
}, [])
|
||||
|
||||
async function checkAppUpdate(): Promise<void> {
|
||||
setAppChecking(true)
|
||||
setAppUpd(null)
|
||||
setAppUpdError(null)
|
||||
try {
|
||||
const info = await window.api.checkForAppUpdate()
|
||||
// Funnel a failed check into the single error slot rather than storing a
|
||||
// second not-ok AppUpdateInfo, so only one error message can ever render.
|
||||
if (info.ok) setAppUpd(info)
|
||||
else setAppUpdError(info.error ?? 'Update check failed.')
|
||||
} catch (e) {
|
||||
setAppUpdError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setAppChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function installAppUpdate(): Promise<void> {
|
||||
if (!appUpd?.downloadUrl) return
|
||||
setAppDownloading(true)
|
||||
setAppFraction(undefined)
|
||||
setAppUpdError(null)
|
||||
try {
|
||||
const dl = await window.api.downloadAppUpdate(appUpd.downloadUrl)
|
||||
if (!dl.ok || !dl.filePath) {
|
||||
setAppUpdError(dl.error ?? 'Download failed.')
|
||||
return
|
||||
}
|
||||
const run = await window.api.runAppUpdate(dl.filePath)
|
||||
// On success the app quits as the installer launches; only errors return here.
|
||||
if (!run.ok) setAppUpdError(run.error ?? 'Could not start the installer.')
|
||||
} catch (e) {
|
||||
setAppUpdError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setAppDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function exportBackup(): Promise<void> {
|
||||
setExporting(true)
|
||||
setExportResult(null)
|
||||
@@ -328,17 +396,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>
|
||||
|
||||
@@ -731,6 +831,69 @@ export function SettingsView(): React.JSX.Element {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<ArrowSyncRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Software update</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
{appVersion
|
||||
? `You're running AeroFetch v${appVersion}. Updates are fetched from the AeroFetch release repo.`
|
||||
: 'Checking your AeroFetch version…'}
|
||||
</Caption1>
|
||||
|
||||
<div className={styles.folderRow}>
|
||||
<Button
|
||||
icon={<ArrowSyncRegular />}
|
||||
onClick={checkAppUpdate}
|
||||
disabled={appChecking || appDownloading}
|
||||
>
|
||||
{appChecking ? 'Checking…' : 'Check for updates'}
|
||||
</Button>
|
||||
{appChecking && <Spinner size="tiny" />}
|
||||
</div>
|
||||
|
||||
{appUpd?.ok && appUpd.available && (
|
||||
<>
|
||||
<Text style={{ fontWeight: tokens.fontWeightSemibold }}>
|
||||
Version {appUpd.latestVersion} is available — here's what changed:
|
||||
</Text>
|
||||
{appUpd.notes && <div className={styles.notesBox}>{appUpd.notes}</div>}
|
||||
<div className={styles.folderRow}>
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={<ArrowDownloadRegular />}
|
||||
onClick={installAppUpdate}
|
||||
disabled={appDownloading || !appUpd.downloadUrl}
|
||||
>
|
||||
{appDownloading ? 'Downloading…' : 'Update now'}
|
||||
</Button>
|
||||
{appUpd.htmlUrl && (
|
||||
<Button icon={<OpenRegular />} onClick={() => window.open(appUpd.htmlUrl, '_blank')}>
|
||||
View release
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!appUpd.downloadUrl && (
|
||||
<Caption1 className={styles.hint}>
|
||||
This release has no installer attached — use “View release” to download it manually.
|
||||
</Caption1>
|
||||
)}
|
||||
{appDownloading && <ProgressBar value={appFraction} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{appUpd?.ok && !appUpd.available && (
|
||||
<Text>You're up to date — v{appUpd.currentVersion} is the latest.</Text>
|
||||
)}
|
||||
|
||||
{appUpdError && (
|
||||
<Caption1 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
|
||||
{appUpdError}
|
||||
</Caption1>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<InfoRegular className={styles.sectionIcon} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,27 @@ if (import.meta.env.DEV && !window.api) {
|
||||
]
|
||||
|
||||
window.api = {
|
||||
getAppVersion: async () => '0.4.0-preview',
|
||||
checkForAppUpdate: async () => {
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
return {
|
||||
ok: true,
|
||||
available: true,
|
||||
currentVersion: '0.4.0',
|
||||
latestVersion: '0.5.0',
|
||||
notes:
|
||||
'### What’s new in v0.5.0\n- In-app updater (this screen!)\n- Faster downloads\n- Bug fixes',
|
||||
htmlUrl: 'https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases',
|
||||
downloadUrl: 'https://gitea.netbird.zimspace.uk:5938/example/AeroFetch-Setup-0.5.0.exe',
|
||||
assetName: 'AeroFetch-Setup-0.5.0.exe'
|
||||
}
|
||||
},
|
||||
downloadAppUpdate: async () => ({
|
||||
ok: false,
|
||||
error: 'Downloading updates is disabled in the browser preview.'
|
||||
}),
|
||||
runAppUpdate: async () => ({ ok: true }),
|
||||
onAppUpdateProgress: () => () => {},
|
||||
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 +143,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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+70
-4
@@ -4,6 +4,16 @@
|
||||
*/
|
||||
|
||||
export const IpcChannels = {
|
||||
/** the AeroFetch app version (package.json / app.getVersion) */
|
||||
appVersion: 'app:version',
|
||||
/** check the configured Gitea repo for a newer AeroFetch release */
|
||||
appUpdateCheck: 'app:update-check',
|
||||
/** download the latest release's installer to a temp file */
|
||||
appUpdateDownload: 'app:update-download',
|
||||
/** launch a downloaded installer and quit so it can replace the app */
|
||||
appUpdateRun: 'app:update-run',
|
||||
/** main → renderer push channel for installer download progress */
|
||||
appUpdateProgress: 'app:update-progress',
|
||||
ytdlpVersion: 'ytdlp:version',
|
||||
probe: 'media:probe',
|
||||
downloadStart: 'download:start',
|
||||
@@ -186,8 +196,62 @@ export interface YtdlpVersionResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** yt-dlp's self-update release channel (`--update-to <channel>`). */
|
||||
export type YtdlpUpdateChannel = 'stable' | 'nightly'
|
||||
/** Result of checking the configured Gitea repo for a newer AeroFetch release. */
|
||||
export interface AppUpdateInfo {
|
||||
ok: boolean
|
||||
/** true when latestVersion is newer than the running app */
|
||||
available: boolean
|
||||
/** the running app's version, e.g. '0.4.0' */
|
||||
currentVersion: string
|
||||
/** the latest release's version (tag without a leading 'v'), when ok */
|
||||
latestVersion?: string
|
||||
/** the release notes / changelog body (Markdown), shown before updating */
|
||||
notes?: string
|
||||
/** the release's web page on Gitea (for "View release") */
|
||||
htmlUrl?: string
|
||||
/** direct download URL of the Windows installer asset, when the release has one */
|
||||
downloadUrl?: string
|
||||
/** the installer asset's file name */
|
||||
assetName?: string
|
||||
/** human-readable error, when ok is false */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Result of downloading the update installer to a temp file. */
|
||||
export interface AppUpdateDownload {
|
||||
ok: boolean
|
||||
/** absolute path to the downloaded installer, when ok */
|
||||
filePath?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Live progress of the installer download (main → renderer). */
|
||||
export interface AppUpdateProgress {
|
||||
/** bytes downloaded so far */
|
||||
received: number
|
||||
/** total bytes, when the server reports Content-Length */
|
||||
total?: number
|
||||
/** 0..1 fraction, when total is known */
|
||||
fraction?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +404,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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { assertHttpUrl } from '../src/main/url'
|
||||
import { isAllowedLoginUrl } from '../src/main/cookies'
|
||||
import { isTrustedDownloadUrl, extractSha256, compareVersions } from '../src/main/updater'
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
// --- app-updater: isTrustedDownloadUrl — host pin across redirect hops -------
|
||||
|
||||
describe('isTrustedDownloadUrl (app-updater host pin)', () => {
|
||||
const onHost =
|
||||
'https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases/download/v1/AeroFetch-Setup.exe'
|
||||
|
||||
it('accepts https URLs on the exact update host (incl. port)', () => {
|
||||
expect(isTrustedDownloadUrl(onHost)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a different host — the redirect/MITM bounce vector', () => {
|
||||
expect(isTrustedDownloadUrl('https://evil.example/AeroFetch-Setup.exe')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects the right hostname on the wrong port', () => {
|
||||
// host comparison includes the port, so :443 (default) is not the same origin
|
||||
expect(isTrustedDownloadUrl('https://gitea.netbird.zimspace.uk/AeroFetch-Setup.exe')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects plain http even on the update host', () => {
|
||||
expect(isTrustedDownloadUrl('http://gitea.netbird.zimspace.uk:5938/x.exe')).toBe(false)
|
||||
})
|
||||
|
||||
it('is not fooled by the trusted host placed in the userinfo', () => {
|
||||
expect(
|
||||
isTrustedDownloadUrl('https://gitea.netbird.zimspace.uk:5938@evil.example/x.exe')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unparseable input', () => {
|
||||
expect(isTrustedDownloadUrl('not a url')).toBe(false)
|
||||
expect(isTrustedDownloadUrl('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// --- app-updater: extractSha256 — checksum-file parsing ----------------------
|
||||
|
||||
describe('extractSha256 (app-updater checksum parsing)', () => {
|
||||
const hash = 'a'.repeat(64)
|
||||
|
||||
it('reads a bare lowercase digest', () => {
|
||||
expect(extractSha256(hash)).toBe(hash)
|
||||
})
|
||||
|
||||
it('reads sha256sum format (`<hash> filename`)', () => {
|
||||
expect(extractSha256(`${hash} AeroFetch-Setup-0.5.0.exe\n`)).toBe(hash)
|
||||
})
|
||||
|
||||
it('lowercases a PowerShell Get-FileHash (uppercase) digest', () => {
|
||||
expect(extractSha256('A'.repeat(64))).toBe(hash)
|
||||
})
|
||||
|
||||
it('returns null when there is no standalone 64-char hex token', () => {
|
||||
expect(extractSha256('')).toBeNull()
|
||||
expect(extractSha256('not a checksum')).toBeNull()
|
||||
expect(extractSha256('deadbeef')).toBeNull() // too short
|
||||
})
|
||||
|
||||
it('does not slice a 64-char window out of a longer hex run (e.g. sha512)', () => {
|
||||
expect(extractSha256('a'.repeat(128))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// --- app-updater: compareVersions — prerelease never outranks its release ----
|
||||
|
||||
describe('compareVersions (app-updater version ordering)', () => {
|
||||
it('orders by numeric components', () => {
|
||||
expect(compareVersions('0.5.0', '0.4.0')).toBe(1)
|
||||
expect(compareVersions('0.4.0', '0.5.0')).toBe(-1)
|
||||
expect(compareVersions('1.2.3', '1.2.3')).toBe(0)
|
||||
})
|
||||
|
||||
it('treats a leading v as cosmetic', () => {
|
||||
expect(compareVersions('v0.5.0', '0.5.0')).toBe(0)
|
||||
})
|
||||
|
||||
it('never sorts a prerelease above its final release', () => {
|
||||
// 0.5.0-rc.1 must not be offered as an "upgrade" over a running 0.5.0
|
||||
expect(compareVersions('0.5.0-rc.1', '0.5.0')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 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 AeroFetch’s 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
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user