Merge pull request 'feat(setup): fetch ffmpeg on first run instead of bundling it' (#11) from feature/ffmpeg-first-run-download into main

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-07-08 05:42:11 -04:00
33 changed files with 1391 additions and 386 deletions
+2
View File
@@ -10,6 +10,8 @@ dist
# Secrets — never commit
.gitea-token
# Retired: no longer read by the build (baked update token removed), but the
# local file still holds a live token until it's revoked — keep it ignored.
.update-token
# VS Code user settings (workspace settings/.vscode/launch.json are committed)
+8 -2
View File
@@ -28,13 +28,19 @@ files:
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!*.tsbuildinfo'
# yt-dlp.exe + ffmpeg.exe ship outside the asar archive so they can be spawned
# directly. They land in <install>/resources/bin, reachable via process.resourcesPath.
# yt-dlp.exe (+ optional aria2c.exe) ship outside the asar archive so they can be spawned
# directly, landing in <install>/resources/bin (reachable via process.resourcesPath).
# ffmpeg.exe/ffprobe.exe are deliberately EXCLUDED — they're the bulk of the installer, so
# they're fetched on first run into userData/bin instead (src/main/ffmpegSetup.ts). The
# excludes also keep a developer's local resources/bin copies (used only for the dev seed)
# out of the shipped build.
extraResources:
- from: resources/bin
to: bin
filter:
- '**/*'
- '!ffmpeg.exe'
- '!ffprobe.exe'
# The app icon, copied so the runtime (system tray) can load it at
# <resources>/icon.ico — build/ itself isn't bundled into the app.
- from: build/icon.ico
-18
View File
@@ -1,28 +1,10 @@
import { resolve } from 'path'
import { existsSync, readFileSync } from 'fs'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
// Read-only Gitea token compiled into the main bundle so the auto-updater can
// read the PRIVATE release repo without user setup (see config.ts BAKED_UPDATE_TOKEN).
// Source order: AEROFETCH_UPDATE_TOKEN env var (CI/secret), else a gitignored
// .update-token file (local builds). Absent → empty string → no token baked in.
// Keep this a dedicated read-only service-account token; the shipped bundle is public.
function bakedUpdateToken(): string {
const fromEnv = process.env.AEROFETCH_UPDATE_TOKEN?.trim()
if (fromEnv) return fromEnv
const file = resolve('.update-token')
return existsSync(file) ? readFileSync(file, 'utf8').trim() : ''
}
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
// Textually replaces the __AEROFETCH_UPDATE_TOKEN__ identifier in main-process
// code with the token literal at build time — value never lands in source/git.
define: {
__AEROFETCH_UPDATE_TOKEN__: JSON.stringify(bakedUpdateToken())
},
resolve: {
alias: {
'@shared': resolve('src/shared')
+25 -20
View File
@@ -1,19 +1,21 @@
# Bundled binaries
AeroFetch spawns these from the **main process** and bundles them via
electron-builder `extraResources` (they ship outside the asar archive). The
`.exe` binaries are committed to the repo so a fresh clone builds and runs
without a manual download step.
AeroFetch spawns these from the **main process**. `yt-dlp.exe` (and optional
`aria2c.exe`) ship in the installer via electron-builder `extraResources` (outside the
asar archive) and are committed here so a fresh clone builds and runs.
Drop the three required Windows executables here before running `npm run dev` or
building; `aria2c.exe` is optional:
`ffmpeg.exe` + `ffprobe.exe` are **NOT shipped in the installer** — they're the bulk of
its size, so they're downloaded on first run into `userData/bin` (see
`src/main/ffmpegSetup.ts`, pinned in `src/main/config.ts`). The copies here are used only
as a **dev seed**`ensureManagedFfmpeg` copies them into `userData/bin` so `npm run dev`
needs no download — and are excluded from the shipped build.
```
resources/bin/
├── yt-dlp.exe
├── ffmpeg.exe
├── ffprobe.exe
└── aria2c.exe (optional)
├── yt-dlp.exe (bundled + committed)
├── ffmpeg.exe (dev seed only — excluded from the installer)
├── ffprobe.exe (dev seed only — excluded from the installer)
└── aria2c.exe (optional, bundled)
```
## yt-dlp.exe
@@ -22,17 +24,20 @@ resources/bin/
- Grab `yt-dlp.exe`.
- Unlicense / public domain.
## ffmpeg.exe + ffprobe.exe
## ffmpeg.exe + ffprobe.exe (dev seed only)
- Use an **LGPL** build to keep licensing simple, e.g.
https://github.com/BtbN/FFmpeg-Builds/releases (pick a `*-lgpl-shared` or
`*-lgpl` win64 build) or https://www.gyan.dev/ffmpeg/builds/.
- Copy **both** `ffmpeg.exe` and `ffprobe.exe` here — they ship together in the
same archive's `bin/` folder, and their versions must match. yt-dlp needs
`ffprobe.exe` to read media durations; without it, duration-dependent
post-processing fails with "ffprobe not found" (`--sponsorblock-remove`,
`--force-keyframes-at-cuts`, and `--split-chapters` all error out).
- Ship the LGPL license text alongside the installer before release.
- The app downloads these on first run from the pinned upstream archive in
`src/main/config.ts` (`FFMPEG_ARCHIVE_URL` + `FFMPEG_ARCHIVE_SHA256`). To bump the
version, edit those constants — recompute the SHA-256 from the new zip — in their own
commit (the "pinned decisions" discipline).
- For local dev, drop **both** `ffmpeg.exe` and `ffprobe.exe` here — they come together in
the same archive's `bin/` folder and their versions must match. yt-dlp needs
`ffprobe.exe` to read media durations; without it, duration-dependent post-processing
fails with "ffprobe not found" (`--sponsorblock-remove`, `--force-keyframes-at-cuts`,
and `--split-chapters` all error out). Any recent win64 build works, e.g.
https://www.gyan.dev/ffmpeg/builds/ or https://github.com/BtbN/FFmpeg-Builds/releases.
- The installer no longer redistributes ffmpeg, so there's no ffmpeg license text to ship
with the build — users fetch the binary from upstream themselves.
## aria2c.exe (optional)
Binary file not shown.
+41 -13
View File
@@ -42,15 +42,20 @@ export function getAppIconImage(): NativeImage {
}
/**
* AeroFetch keeps its OWN writable copy of yt-dlp.exe under userData, separate
* from the read-only bundled seed in resources/bin. The managed copy is what
* actually gets spawned and self-updated (`--update-to`), so an app reinstall or
* portable re-extraction — which only ever replace the bundled seed — can never
* roll a freshly-updated yt-dlp back to a stale version. See ensureManagedYtdlp
* in ytdlp.ts, which seeds this from getBundledYtdlpPath() on first run.
* AeroFetch keeps its OWN writable copies of the tool binaries under userData/bin,
* separate from anything in the read-only bundle (resources/bin). This managed dir is
* what actually gets spawned, and it survives an app reinstall or portable re-extraction
* (which only ever touch the bundle) — so a self-updated yt-dlp is never rolled back to
* a stale bundled version, and the first-run-downloaded ffmpeg/ffprobe (which the
* installer no longer ships at all) persist across updates.
*
* ffmpeg/ffprobe/aria2c stay in getBinDir(): they're not self-updating and
* yt-dlp finds them via `--ffmpeg-location <binDir>`.
* - yt-dlp.exe — seeded from the bundled copy, then self-updates (`--update-to`).
* See ensureManagedYtdlp in ytdlp.ts.
* - ffmpeg.exe / ffprobe.exe — fetched from upstream on first run (ffmpegSetup.ts);
* in dev, ensureManagedFfmpeg seeds them from resources/bin so `npm run dev` needs
* no download. yt-dlp finds them here via `--ffmpeg-location <getFfmpegDir()>`.
*
* aria2c stays in getBinDir(): it's optional, bundled, and passed by absolute path.
*/
function getManagedBinDir(): string {
return join(app.getPath('userData'), 'bin')
@@ -66,17 +71,40 @@ export function getYtdlpPath(): string {
return join(getManagedBinDir(), 'yt-dlp.exe')
}
/**
* The managed ffmpeg.exe under userData/bin. The installer no longer bundles ffmpeg
* (it's the bulk of the download); ffmpegSetup.ts fetches it on first run, and in dev
* ensureManagedFfmpeg seeds it from getBundledFfmpegPath(). yt-dlp locates it (and
* ffprobe) via `--ffmpeg-location getFfmpegDir()`.
*/
export function getFfmpegPath(): string {
return join(getBinDir(), 'ffmpeg.exe')
return join(getManagedBinDir(), 'ffmpeg.exe')
}
/**
* yt-dlp finds ffprobe via --ffmpeg-location (the bin dir), so the app never
* spawns it directly but it must be present, or duration-aware post-processing
* (SponsorBlock-remove, --force-keyframes-at-cuts, --split-chapters) fails. This
* accessor exists so startDownload can assert its presence up front.
* The managed ffprobe.exe under userData/bin. yt-dlp finds it via --ffmpeg-location
* (so the app never spawns it directly), but it must be present, or duration-aware
* post-processing (SponsorBlock-remove, --force-keyframes-at-cuts, --split-chapters)
* fails. startDownload / the setup gate assert its presence up front.
*/
export function getFfprobePath(): string {
return join(getManagedBinDir(), 'ffprobe.exe')
}
/** Directory holding the managed ffmpeg/ffprobe, handed to yt-dlp as --ffmpeg-location. */
export function getFfmpegDir(): string {
return getManagedBinDir()
}
/**
* Dev-only read seeds for ffmpeg/ffprobe in resources/bin, copied into the managed dir
* by ensureManagedFfmpeg when present. Absent in a shipped installer (excluded from the
* bundle in electron-builder.yml), where first-run download provides them instead.
*/
export function getBundledFfmpegPath(): string {
return join(getBinDir(), 'ffmpeg.exe')
}
export function getBundledFfprobePath(): string {
return join(getBinDir(), 'ffprobe.exe')
}
+35 -25
View File
@@ -4,33 +4,43 @@
* timeouts, caps, tickers) and from user settings (settings.ts). Retarget a
* fork's update source by editing these three values.
*
* The update repo is PRIVATE, so the release API + downloads require a token.
* A build-time read-only token (BAKED_UPDATE_TOKEN below) lets a shipped client
* reach it without the user configuring anything; a user-set updateToken in
* Settings still takes precedence. On a build with no token baked in, the update
* check simply reports that it couldn't reach the server.
* The update source is a PUBLIC, releases-only repo — it holds no source, just
* the published installers — so anonymous reads work and the updater sends no
* auth at all. Keeping releases separate from the (private) source repo is what
* lets the client stay tokenless: there is no secret in the bundle to leak. A
* private fork retargets its updater by editing these constants and rebuilding.
*/
export const UPDATE_HOST = 'https://gitea.netbird.zimspace.uk:5938'
export const UPDATE_OWNER = 'debont80'
export const UPDATE_REPO = 'AeroFetch'
export const UPDATE_REPO = 'AeroFetch-releases'
export const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`
/**
* Read-only Gitea token baked in at build time so the auto-updater can read the
* PRIVATE release repo without each user pasting a token into Settings. Injected
* by electron.vite.config's `define` from the AEROFETCH_UPDATE_TOKEN env var (or a
* gitignored .update-token file) — so the real value lives only in the built
* bundle, never in source or git.
*
* SECURITY: a shipped client is decompilable, so treat this as PUBLIC. It MUST be
* a token from a dedicated service account with read-only access to ONLY this repo
* — never a personal/push-capable token. The user's own updateToken overrides it
* (see authHeader in updater.ts), so a private fork can point elsewhere.
*
* `__AEROFETCH_UPDATE_TOKEN__` is a `define`-replaced literal in built code; under
* plain vitest (no define) the identifier is undeclared, so the `typeof` guard
* keeps this from throwing and falls back to empty (no token).
*/
declare const __AEROFETCH_UPDATE_TOKEN__: string
export const BAKED_UPDATE_TOKEN: string =
typeof __AEROFETCH_UPDATE_TOKEN__ === 'string' ? __AEROFETCH_UPDATE_TOKEN__ : ''
// --- ffmpeg dependency source ------------------------------------------------
// ffmpeg/ffprobe are NOT bundled in the installer (they're the bulk of its size);
// they're fetched once on first run into the managed userData/bin dir (ffmpegSetup.ts).
//
// Unlike the app updater — which pulls from our own host-pinned release server — this
// downloads from UPSTREAM (gyan.dev's GitHub releases). Integrity therefore rests on a
// PINNED exact-version URL + SHA-256 here, re-verified against the streamed bytes: a
// swapped or tampered upstream asset fails the check and nothing is installed. Bumping
// ffmpeg is a deliberate edit to these three constants in its own commit (recompute the
// hash from the new zip) — the same "pinned decisions" discipline the app version follows.
//
// Licensing note: gyan's "essentials" is a GPL build. That's fine here because AeroFetch
// never redistributes it — it invokes ffmpeg.exe as a separate process (as yt-dlp does)
// and the user fetches the binary from upstream themselves. Swapping to an LGPL build
// (e.g. a BtbN win64 build) is purely a change to FFMPEG_ARCHIVE_URL/SHA-256 below,
// provided the zip still carries ffmpeg.exe + ffprobe.exe under a `*/bin/` path.
export const FFMPEG_VERSION = '8.1.2'
export const FFMPEG_ARCHIVE_URL = `https://github.com/GyanD/codexffmpeg/releases/download/${FFMPEG_VERSION}/ffmpeg-${FFMPEG_VERSION}-essentials_build.zip`
export const FFMPEG_ARCHIVE_SHA256 =
'db580001caa24ac104c8cb856cd113a87b0a443f7bdf47d8c12b1d740584a2ec'
// The setup UI quotes ~105 MB for this archive; keep that copy in sync when bumping above.
// Hosts the archive download may touch, HTTPS-only, re-checked on EVERY redirect hop
// (github.com 302s a release asset to release-assets.githubusercontent.com; the older
// objects.githubusercontent.com is kept for resilience). Anything off this list fails safe.
export const FFMPEG_TRUSTED_HOSTS = [
'github.com',
'release-assets.githubusercontent.com',
'objects.githubusercontent.com'
] as const
+8
View File
@@ -13,6 +13,14 @@
export const VERSION_TIMEOUT_MS = 15_000
/** yt-dlp self-update run — can pull a fresh binary, so a touch longer. */
export const YTDLP_UPDATE_TIMEOUT_MS = 60_000
/**
* How long the first-run ffmpeg archive download may STALL (no bytes received)
* before it's abandoned (ffmpegSetup.ts). Same generous idle budget the app-update
* download uses; total time is otherwise unbounded (the archive is ~105 MB).
*/
export const FFMPEG_DOWNLOAD_IDLE_TIMEOUT_MS = 60_000
/** Unpacking the two ffmpeg exes with System32 tar.exe — bounded so a wedged extract can't hang setup. */
export const FFMPEG_EXTRACT_TIMEOUT_MS = 120_000
/** Best-effort metadata --print alongside a download (title/uploader/duration). */
export const META_PROBE_TIMEOUT_MS = 30_000
/** Full format/metadata probe (`-J`) for the download bar. */
+3 -2
View File
@@ -4,8 +4,9 @@
* This module is deliberately free of any electron / node-runtime dependency
* (it only imports types + the BEST_FORMAT_ID const from the shared contract)
* so the argv it produces can be unit-tested without spinning up Electron.
* `buildArgs` takes the ffmpeg/yt-dlp `binDir` as a parameter rather than
* resolving it itself; the caller in download.ts passes `getBinDir()`.
* `buildArgs` takes the `binDir` as a parameter (used only for `--ffmpeg-location`)
* rather than resolving it itself; download.ts passes `getFfmpegDir()` — the managed
* userData/bin where the first-run download installs ffmpeg/ffprobe.
*/
import { join } from 'path'
+7 -3
View File
@@ -5,7 +5,7 @@ import { join, parse } from 'path'
import { BrowserWindow, Notification, type WebContents } from 'electron'
import {
getYtdlpPath,
getBinDir,
getFfmpegDir,
getAria2cPath,
getFfmpegPath,
getFfprobePath,
@@ -243,7 +243,9 @@ export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string):
youtubePoToken: settings.youtubePoToken
}
const extraArgs = resolveExtraArgs(opts, settings)
return buildArgs({ opts, outputTemplate, options, binDir: getBinDir(), access, extraArgs })
// binDir feeds only `--ffmpeg-location`, so it's the managed ffmpeg dir (userData/bin),
// where the first-run download / dev seed places ffmpeg.exe + ffprobe.exe.
return buildArgs({ opts, outputTemplate, options, binDir: getFfmpegDir(), access, extraArgs })
}
/** Build the exact command line for the current form state, without running it. */
@@ -285,9 +287,11 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
if (!existsSync(getFfmpegPath())) missingBins.push('ffmpeg.exe')
if (!existsSync(getFfprobePath())) missingBins.push('ffprobe.exe')
if (missingBins.length > 0) {
// The first-run setup gate normally guarantees these are present; this backstops a
// mid-session loss (AV quarantine, a cleared userData/bin) with a clear repair path.
return {
ok: false,
error: `${missingBins.join(' and ')} not found in ${getBinDir()}. Add the ffmpeg build's binaries to resources/bin/ (see the README there).`
error: `${missingBins.join(' and ')} not found. Open Settings → About and choose "Repair ffmpeg" to reinstall the media tools.`
}
}
// Reject anything that isn't an http(s) URL before it reaches yt-dlp's argv,
+1 -1
View File
@@ -21,7 +21,7 @@ async function readToolVersion(path: string): Promise<string | null> {
return m?.[1] ?? null
}
/** Versions of the bundled ffmpeg + ffprobe, read in parallel for the Settings panel. */
/** Versions of the managed ffmpeg + ffprobe, read in parallel for the Settings panel. */
export async function getFfmpegVersions(): Promise<FfmpegVersionResult> {
const [ffmpeg, ffprobe] = await Promise.all([
readToolVersion(getFfmpegPath()),
+249
View File
@@ -0,0 +1,249 @@
import { app, dialog, type BrowserWindow, type OpenDialogOptions, type WebContents } from 'electron'
import { existsSync, mkdirSync, copyFileSync, renameSync, rmSync } from 'fs'
import { execFile } from 'child_process'
import { join, dirname, basename } from 'path'
import {
getFfmpegPath,
getFfprobePath,
getFfmpegDir,
getBundledFfmpegPath,
getBundledFfprobePath,
getSystem32Path
} from './binaries'
import { getFfmpegVersions } from './ffmpeg'
import { streamVerifiedFile } from './lib/verifiedDownload'
import { FFMPEG_ARCHIVE_URL, FFMPEG_ARCHIVE_SHA256, FFMPEG_TRUSTED_HOSTS } from './config'
import { VERSION_TIMEOUT_MS, FFMPEG_EXTRACT_TIMEOUT_MS } from './constants'
import {
IpcChannels,
type FfmpegSetupStatus,
type FfmpegSetupProgress,
type FfmpegSetupResult
} from '@shared/ipc'
import { logger } from './logger'
/**
* First-run acquisition of ffmpeg + ffprobe. The installer no longer bundles them
* (they're the bulk of its size); they live in the managed userData/bin dir (like the
* self-updating yt-dlp) and are fetched once on first run from a PINNED upstream archive,
* verified against a pinned SHA-256, then unpacked with the OS's tar.exe. See config.ts
* for the source + trust rationale, and binaries.ts for why the managed dir is used.
*/
/**
* Trust gate for the ffmpeg archive: HTTPS + an allowlisted upstream host, re-checked on
* every redirect hop by streamVerifiedFile (github.com 302s to a githubusercontent host).
* Mirrors updater.ts's isTrustedDownloadUrl but for the upstream host set, not our own.
*/
export function isTrustedFfmpegUrl(url: string): boolean {
try {
const u = new URL(url)
return u.protocol === 'https:' && (FFMPEG_TRUSTED_HOSTS as readonly string[]).includes(u.host)
} catch {
return false
}
}
/** Both binaries present in the managed dir — the readiness the download gate keys on. */
function bothPresent(): boolean {
return existsSync(getFfmpegPath()) && existsSync(getFfprobePath())
}
/** Copy a dev seed into the managed dir when the managed copy is missing. */
function seedOne(seed: string, managed: string): void {
if (existsSync(managed) || !existsSync(seed)) return
try {
mkdirSync(dirname(managed), { recursive: true })
copyFileSync(seed, managed)
} catch {
/* best-effort; a failed seed just leaves the managed copy absent → first-run download */
}
}
/**
* Ensure the managed ffmpeg/ffprobe exist, seeding them from the read-only bundle when
* present (dev checkouts keep them in resources/bin, so `npm run dev` needs no download).
* A shipped installer carries no seed, so this no-ops and first-run download provides them.
* Best-effort and idempotent cheap to call at startup and before the presence gate.
* Mirrors ensureManagedYtdlp in ytdlp.ts.
*/
export function ensureManagedFfmpeg(): void {
seedOne(getBundledFfmpegPath(), getFfmpegPath())
seedOne(getBundledFfprobePath(), getFfprobePath())
}
/** Readiness + versions for the setup gate and the About card. */
export async function getFfmpegSetupStatus(): Promise<FfmpegSetupStatus> {
// Seed from the dev bundle first so a seeded checkout reports ready without a download.
ensureManagedFfmpeg()
const versions = await getFfmpegVersions()
return { ready: bothPresent(), ffmpeg: versions.ffmpeg, ffprobe: versions.ffprobe }
}
// The in-flight download's canceller (armed only during the network phase; extraction is
// not cancelable) and a single-flight guard shared by download + locate.
let activeCancel: (() => void) | null = null
let extracting = false
/** Abort the in-flight ffmpeg archive download, if any (the setup card's Cancel). */
export function cancelFfmpegDownload(): void {
activeCancel?.()
}
/**
* Download the pinned ffmpeg archive, verify its SHA-256, unpack ffmpeg.exe + ffprobe.exe
* into the managed dir, and clean up. Progress (download %, then an indeterminate
* "extracting" phase) is pushed to the renderer. Single-flight; the network phase is
* cancelable via cancelFfmpegDownload().
*/
export async function downloadFfmpeg(wc: WebContents): Promise<FfmpegSetupResult> {
if (activeCancel || extracting) {
return { ok: false, error: 'An ffmpeg download is already in progress.' }
}
if (!isTrustedFfmpegUrl(FFMPEG_ARCHIVE_URL)) {
return { ok: false, error: 'Refused to download ffmpeg from an untrusted location.' }
}
const tempZip = join(app.getPath('temp'), 'aerofetch-ffmpeg.zip')
const extractDir = join(app.getPath('temp'), `aerofetch-ffmpeg-${Date.now()}`)
const send = (p: FfmpegSetupProgress): void => {
if (!wc.isDestroyed()) wc.send(IpcChannels.ffmpegSetupProgress, p)
}
try {
const dl = await streamVerifiedFile({
url: FFMPEG_ARCHIVE_URL,
filePath: tempZip,
expectedSha: FFMPEG_ARCHIVE_SHA256,
isTrusted: isTrustedFfmpegUrl,
onProgress: (p) =>
send({ phase: 'downloading', received: p.received, total: p.total, fraction: p.fraction }),
onCancelReady: (cancel) => {
activeCancel = cancel
},
onSettled: (cancel) => {
if (activeCancel === cancel) activeCancel = null
},
messages: {
checksumMismatch:
'The ffmpeg download failed its checksum check — it may be corrupt or tampered with. Nothing was installed.',
canceled: 'ffmpeg download canceled.'
}
})
if (!dl.ok) return { ok: false, error: dl.error }
extracting = true
send({ phase: 'extracting' })
await extractArchive(tempZip, extractDir)
installFromDir(extractDir)
if (!bothPresent()) {
return { ok: false, error: 'The ffmpeg archive did not contain the expected binaries.' }
}
return { ok: true }
} catch (e) {
logger.error('[ffmpeg-setup] download failed', e instanceof Error ? e.message : String(e))
return { ok: false, error: e instanceof Error ? e.message : String(e) }
} finally {
extracting = false
activeCancel = null
rmSync(tempZip, { force: true })
rmSync(extractDir, { recursive: true, force: true })
}
}
/**
* Unpack just ffmpeg.exe + ffprobe.exe (flattened) from the archive using the OS's bundled
* bsdtar. Resolve tar.exe by absolute System32 path, never the bare name, so a planted
* tar.exe on PATH / in the CWD can't run in its place (audit F3). The two glob patterns
* (matching the `bin/ffmpeg.exe` + `bin/ffprobe.exe` members) are applied by tar itself
* execFile does no shell globbing and --strip-components=2 drops the leading
* `<build>/bin/` path so the exes land flat in destDir.
*/
function extractArchive(zip: string, destDir: string): Promise<void> {
mkdirSync(destDir, { recursive: true })
return new Promise((resolve, reject) => {
execFile(
getSystem32Path('tar.exe'),
['-xf', zip, '-C', destDir, '--strip-components=2', '*/bin/ffmpeg.exe', '*/bin/ffprobe.exe'],
{ timeout: FFMPEG_EXTRACT_TIMEOUT_MS, windowsHide: true },
(err) => (err ? reject(err) : resolve())
)
})
}
/** Move the two extracted exes into the managed dir. */
function installFromDir(dir: string): void {
mkdirSync(getFfmpegDir(), { recursive: true })
installOne(join(dir, 'ffmpeg.exe'), getFfmpegPath())
installOne(join(dir, 'ffprobe.exe'), getFfprobePath())
}
/**
* Place one binary at its managed path via a temp copy + rename, so a concurrent read
* never sees a half-written exe (rename is atomic within a volume). Falls back to an
* in-place overwrite only if the rename is blocked by a same-name destination.
*/
function installOne(src: string, dest: string): void {
if (!existsSync(src)) throw new Error(`the ffmpeg archive is missing ${basename(dest)}`)
const tmp = `${dest}.part`
copyFileSync(src, tmp)
try {
renameSync(tmp, dest)
} catch {
copyFileSync(tmp, dest)
rmSync(tmp, { force: true })
}
}
/** Run `<tool> -version` to confirm a manually-located binary is actually runnable. */
function validateTool(path: string): Promise<boolean> {
return new Promise((resolve) => {
execFile(
path,
['-version'],
{ timeout: VERSION_TIMEOUT_MS, windowsHide: true },
(err, stdout) => resolve(!err && /version/i.test(String(stdout)))
)
})
}
/**
* Offline / air-gapped fallback: let the user point at a folder that already holds
* ffmpeg.exe + ffprobe.exe (or its `bin/` subfolder), validate both actually run, and
* copy them into the managed dir. This is what keeps the hard first-run gate escapable
* without a network connection.
*/
export async function locateFfmpegManually(win?: BrowserWindow): Promise<FfmpegSetupResult> {
if (activeCancel || extracting) {
return { ok: false, error: 'An ffmpeg download is already in progress.' }
}
const opts: OpenDialogOptions = {
properties: ['openDirectory'],
title: 'Select the folder containing ffmpeg.exe and ffprobe.exe'
}
const res = win ? await dialog.showOpenDialog(win, opts) : await dialog.showOpenDialog(opts)
if (res.canceled || !res.filePaths[0]) return { ok: false, error: 'No folder selected.' }
const chosen = res.filePaths[0]
for (const cand of [chosen, join(chosen, 'bin')]) {
const ffmpeg = join(cand, 'ffmpeg.exe')
const ffprobe = join(cand, 'ffprobe.exe')
if (!existsSync(ffmpeg) || !existsSync(ffprobe)) continue
const runnable = (await validateTool(ffmpeg)) && (await validateTool(ffprobe))
if (!runnable) {
return {
ok: false,
error: 'Those binaries could not be run — they may be blocked or corrupt.'
}
}
try {
mkdirSync(getFfmpegDir(), { recursive: true })
installOne(ffmpeg, getFfmpegPath())
installOne(ffprobe, getFfprobePath())
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
return { ok: true }
}
return { ok: false, error: "That folder doesn't contain ffmpeg.exe and ffprobe.exe." }
}
+7
View File
@@ -10,6 +10,7 @@ import {
getSystemThemeInfo
} from './ipc'
import { runStartupYtdlpAutoUpdate } from './ytdlp'
import { ensureManagedFfmpeg } from './ffmpegSetup'
import { hasActiveDownloads } from './download'
import { getSettings, applyLaunchAtStartup, migrateSecretsAtRest } from './settings'
import { ensureMediaDirs } from './paths'
@@ -311,6 +312,12 @@ if (isPrimaryInstance) {
}
])
// Seed the managed ffmpeg/ffprobe from the dev bundle when present, so a dev checkout
// (and any transitional build that still ships them) is ready without a download. In a
// shipped installer there's no seed, so this no-ops and the renderer's setup gate
// triggers the first-run download instead. Cheap + best-effort.
ensureManagedFfmpeg()
// Keep yt-dlp current on its own: seed the managed copy, then (throttled to
// once a day) self-update it to the chosen channel so a stale binary can't
// silently cause YouTube 403s. Best-effort and off the critical path — it
+22
View File
@@ -28,6 +28,13 @@ import {
import { PAGE_BACKGROUND } from '@shared/theme'
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
import { getFfmpegVersions } from './ffmpeg'
import {
getFfmpegSetupStatus,
downloadFfmpeg,
cancelFfmpegDownload,
locateFfmpegManually
} from './ffmpegSetup'
import { getAria2cPath } from './binaries'
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate, cancelAppUpdate } from './updater'
import { probeMedia } from './probe'
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
@@ -114,6 +121,21 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null):
ipcMain.handle(IpcChannels.ffmpegVersion, () => getFfmpegVersions())
// First-run ffmpeg/ffprobe acquisition (they're no longer bundled). Status gates the
// setup screen; download streams the pinned archive to e.sender; locate is the offline
// fallback (folder picker parented to the window, like chooseFolder).
ipcMain.handle(IpcChannels.ffmpegSetupStatus, () => getFfmpegSetupStatus())
ipcMain.handle(IpcChannels.ffmpegSetupDownload, (e) => downloadFfmpeg(e.sender))
ipcMain.handle(IpcChannels.ffmpegSetupCancel, () => cancelFfmpegDownload())
ipcMain.handle(IpcChannels.ffmpegSetupLocate, (e) =>
locateFfmpegManually(BrowserWindow.fromWebContents(e.sender) ?? undefined)
)
// Whether the optional aria2c external downloader shipped with this build, so the
// Settings toggle can disable itself instead of silently doing nothing (the flag
// is a no-op without the binary — see download.ts's existsSync gate).
ipcMain.handle(IpcChannels.aria2cAvailable, () => existsSync(getAria2cPath()))
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
ipcMain.handle(IpcChannels.downloadStart, async (e, opts: StartDownloadOptions) => {
+183
View File
@@ -0,0 +1,183 @@
import { net } from 'electron'
import { createWriteStream, type WriteStream } from 'fs'
import { unlink } from 'fs/promises'
import { createHash } from 'crypto'
/** Result of streaming a verified download to disk. */
export interface VerifiedDownloadResult {
ok: boolean
/** absolute path to the written file, when ok */
filePath?: string
error?: string
}
/** Live byte progress of a streaming download (main → renderer). */
export interface VerifiedDownloadProgress {
/** bytes received so far */
received: number
/** total bytes, when the server reports Content-Length */
total?: number
/** 0..1 fraction, when total is known */
fraction?: number
}
/** Context-specific message overrides; the defaults are generic and suit any caller. */
export interface VerifiedDownloadMessages {
/** shown when the streamed bytes don't match expectedSha */
checksumMismatch?: string
/** shown when the caller-armed cancel fires */
canceled?: string
}
export interface StreamVerifiedFileOptions {
url: string
filePath: string
/** published SHA-256 (lowercase hex) to verify against, or null to skip verification */
expectedSha: string | null
/** per-hop trust gate: a URL is fetched or followed only if this returns true for it */
isTrusted: (url: string) => boolean
onProgress: (p: VerifiedDownloadProgress) => void
onCancelReady: (cancel: () => void) => void
onSettled: (cancel: () => void) => void
/** stall budget: how long with no bytes received before abandoning (default 60s) */
idleTimeoutMs?: number
messages?: VerifiedDownloadMessages
}
/** Default stall (no-bytes) budget before a download is abandoned. */
const DEFAULT_IDLE_TIMEOUT_MS = 60_000
/**
* Stream one HTTPS resource into a file with full download discipline: per-hop redirect
* re-validation against a caller-supplied trust gate, streaming SHA-256 verification,
* idle-timeout, content-length truncation detection, write-stream backpressure, and a
* single teardown point that aborts the request and removes the partial file on every
* failure path.
*
* Extracted from the app updater (CL4) so the same audited loop backs both the installer
* download and the first-run ffmpeg fetch; the differences (which hosts are trusted, the
* checksum source, a couple of user-facing strings) are parameters, not forks. net.request
* (not fetch) is used so the host can be re-validated on EVERY redirect hop undici's
* fetch hides the Location header under redirect:'manual', so it can't gate hops.
*
* The caller owns the cancel slot: `onCancelReady` hands it this download's cancel
* function once armed, and `onSettled` hands the same function back on completion so the
* caller can release the slot only if this download still owns it.
*/
export function streamVerifiedFile(
opts: StreamVerifiedFileOptions
): Promise<VerifiedDownloadResult> {
const { url, filePath, expectedSha, isTrusted, onProgress, onCancelReady, onSettled } = opts
const idleTimeoutMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
const canceledMsg = opts.messages?.canceled ?? 'Download canceled.'
const checksumMsg =
opts.messages?.checksumMismatch ??
'The download failed its checksum check — it may be corrupt or tampered with.'
return new Promise<VerifiedDownloadResult>((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: VerifiedDownloadResult): void => {
if (settled) return
settled = true
onSettled(requestCancel)
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 file lying around.
if (fileStream && !fileStream.destroyed) fileStream.destroy()
unlink(filePath).catch(() => {})
}
resolve(result)
}
// Expose this download's abort so the caller's Cancel routes through the same
// teardown — request.abort() + partial cleanup.
const requestCancel = (): void => finish({ ok: false, error: canceledMsg })
onCancelReady(requestCancel)
// 'manual' 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.' })
}, idleTimeoutMs)
}
request.on('redirect', (_status, _method, redirectUrl) => {
if (!isTrusted(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 large download 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?.())
}
onProgress({ received, total, fraction: total ? received / total : undefined })
})
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 used.
if (total !== undefined && received !== total) {
finish({ ok: false, error: 'The download was incomplete — please try again.' })
return
}
// Verify the bytes we wrote match the published SHA-256.
if (expectedSha && hash.digest('hex') !== expectedSha) {
finish({ ok: false, error: checksumMsg })
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()
})
}
+5 -5
View File
@@ -62,12 +62,12 @@ function getStore(): Store<Settings> {
let cachedSettings: Settings | null = null
// --- Credential encryption at rest ------------------------------------------
// proxy / youtubePoToken / updateToken can carry secrets (a proxy password, API
// tokens). They're stored encrypted via Electron safeStorage (DPAPI on Windows)
// so a leaked settings.json doesn't expose them. They're decrypted before
// reaching the renderer; backup export strips them entirely (see backup.ts, M22).
// proxy / youtubePoToken can carry secrets (a proxy password, API tokens).
// They're stored encrypted via Electron safeStorage (DPAPI on Windows) so a
// leaked settings.json doesn't expose them. They're decrypted before reaching
// the renderer; backup export strips them entirely (see backup.ts, M22).
// Exported so backup.ts shares this one list rather than keeping a parallel copy.
export const SECRET_KEYS = ['proxy', 'youtubePoToken', 'updateToken'] as const
export const SECRET_KEYS = ['proxy', 'youtubePoToken'] as const
// Marks a value produced by encryptSecret, so a read can tell ciphertext from a
// legacy plaintext value (written before encryption existed, or while safeStorage
+1 -3
View File
@@ -102,9 +102,7 @@ export const SETTINGS_FIELD_SCHEMAS: { [K in keyof Settings]: z.ZodType<Settings
youtubePlayerClient: z.string().transform((v) => v.trim()),
// Credential-bearing fields — settings.ts encrypts these AFTER parsing.
proxy: z.string(),
youtubePoToken: z.string(),
// An access token has no spaces; trim before encrypting (like proxy creds).
updateToken: z.string().transform((v) => v.trim())
youtubePoToken: z.string()
}
/**
+18 -182
View File
@@ -1,34 +1,9 @@
import { app, net, shell, type WebContents } from 'electron'
import { createWriteStream, type WriteStream } from 'fs'
import { stat, unlink } from 'fs/promises'
import { stat } from 'fs/promises'
import { join, normalize, dirname } from 'path'
import { createHash } from 'crypto'
import { getSettings } from './settings'
import { UPDATE_HOST, RELEASE_API, BAKED_UPDATE_TOKEN } from './config'
import {
IpcChannels,
type AppUpdateInfo,
type AppUpdateDownload,
type AppUpdateProgress
} from '@shared/ipc'
/**
* Authorization header for the update host. The user's updateToken from Settings
* wins if set (lets a private fork target its own repo); otherwise it falls back
* to BAKED_UPDATE_TOKEN the read-only token compiled in at build time so a
* stock client can read the private release repo with no setup. Empty (no header)
* only when neither is present.
*
* The token must never reach an origin other than the host-pinned UPDATE_HOST.
* Every request that carries it guards against that on its own terms: the REST
* check refuses to follow redirects (`redirect: 'error'`), and the download paths
* use `redirect: 'manual'` and re-validate each hop with isTrustedDownloadUrl. So
* a redirect can't bounce the header to another host on any path.
*/
function authHeader(): Record<string, string> {
const tok = getSettings().updateToken?.trim() || BAKED_UPDATE_TOKEN
return tok ? { Authorization: `token ${tok}` } : {}
}
import { UPDATE_HOST, RELEASE_API } from './config'
import { streamVerifiedFile } from './lib/verifiedDownload'
import { IpcChannels, type AppUpdateInfo, type AppUpdateDownload } from '@shared/ipc'
// --- Update source -----------------------------------------------------------
// The Gitea repo whose Releases host the AeroFetch installers lives in
@@ -141,10 +116,10 @@ export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
const timer = setTimeout(() => controller.abort(), 15_000)
try {
const res = await fetch(RELEASE_API, {
headers: { Accept: 'application/json', ...authHeader() },
headers: { Accept: 'application/json' },
// The API lives at an exact pinned URL and answers 200 directly. Refusing
// redirects keeps an authenticated check from ever forwarding the token to
// another origin; a stray redirect fails safe into the catch below.
// redirects keeps the check pinned to that origin; a stray redirect (e.g. to
// a sign-in page) fails safe into the catch below rather than being followed.
redirect: 'error',
signal: controller.signal
})
@@ -191,9 +166,6 @@ export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
}
}
/** How long the download may stall (no bytes received) before we give up. */
const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000
// The in-flight installer download's canceller, set while a download runs and
// cleared on settle. During the pre-download checksum phase it just flags the
// cancel; during the download phase it routes through `finish()` (abort the
@@ -238,7 +210,6 @@ function fetchTrustedText(
resolve(r)
}
const request = net.request({ url, redirect: 'manual' })
for (const [k, v] of Object.entries(authHeader())) request.setHeader(k, v)
const timer = setTimeout(() => done({ ok: false, error: 'timed out' }), timeoutMs)
request.on('redirect', (_s, _m, redirectUrl) => {
if (!isTrustedDownloadUrl(redirectUrl)) {
@@ -269,149 +240,6 @@ function fetchTrustedText(
})
}
/**
* Stream one HTTPS resource into a file with the updater's full download
* discipline (CL4's extracted helper; the once-wrapped event-emitter API CC5
* calls for): per-hop redirect re-validation against the trusted host, streaming
* SHA-256, idle-timeout, content-length truncation detection, write-stream
* backpressure, and single-point teardown that aborts the request and removes
* the partial file on every failure path.
*
* The caller owns the module-level cancel slot: `onCancelReady` hands it this
* download's cancel function once armed, and `onSettled` hands the same function
* back on completion so the caller can release the slot only if this download
* still owns it (the B6 ownership rule).
*/
function streamInstallerToFile(opts: {
url: string
filePath: string
/** published SHA-256 to verify against, or null to skip verification */
expectedSha: string | null
onProgress: (p: AppUpdateProgress) => void
onCancelReady: (cancel: () => void) => void
onSettled: (cancel: () => void) => void
}): Promise<AppUpdateDownload> {
const { url, filePath, expectedSha, onProgress, onCancelReady, onSettled } = opts
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
onSettled(requestCancel)
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)
}
// Expose this download's abort so cancelAppUpdate() (the update card's Cancel)
// can route through the same teardown — request.abort() + partial cleanup. (B6)
const requestCancel = (): void => finish({ ok: false, error: 'Update download canceled.' })
onCancelReady(requestCancel)
// 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' })
for (const [k, v] of Object.entries(authHeader())) request.setHeader(k, v)
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?.())
}
onProgress({
received,
total,
fraction: total ? received / total : undefined
})
})
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()
})
}
/** Stream the installer to a temp file, pushing progress events to the renderer. */
export async function downloadAppUpdate(wc: WebContents, url: string): Promise<AppUpdateDownload> {
if (!isTrustedDownloadUrl(url)) {
@@ -465,15 +293,17 @@ export async function downloadAppUpdate(wc: WebContents, url: string): Promise<A
}
// The streaming itself — redirects, hashing, timeout, truncation, teardown —
// lives in streamInstallerToFile (CL4). This function stays a linear
// pre-flight: trust-check → checksum resolution → stream → done. The cancel
// lives in streamVerifiedFile (lib/verifiedDownload, CL4). This function stays a
// linear pre-flight: trust-check → checksum resolution → stream → done. The cancel
// slot's B6 ownership dance stays here, next to the module state it guards:
// ownership moves from the checksum-phase earlyCancel above to the stream's
// cancel, and is released only if this download still owns the slot.
return streamInstallerToFile({
return streamVerifiedFile({
url,
filePath,
expectedSha,
// Re-check the trusted host on every redirect hop (Gitea 302s to its attachment store).
isTrusted: isTrustedDownloadUrl,
onProgress: (progress) => {
if (!wc.isDestroyed()) wc.send(IpcChannels.appUpdateProgress, progress)
},
@@ -482,6 +312,12 @@ export async function downloadAppUpdate(wc: WebContents, url: string): Promise<A
},
onSettled: (cancel) => {
if (cancelActiveDownload === cancel) cancelActiveDownload = null
},
// Preserve the update card's exact wording (the generic defaults are for other callers).
messages: {
checksumMismatch:
'The downloaded update failed its checksum check — it may be corrupt or tampered with. Nothing was installed.',
canceled: 'Update download canceled.'
}
})
}
+28 -1
View File
@@ -9,6 +9,9 @@ import {
type YtdlpUpdateResult,
type YtdlpAutoUpdateStatus,
type FfmpegVersionResult,
type FfmpegSetupStatus,
type FfmpegSetupProgress,
type FfmpegSetupResult,
type ProbeResult,
type StartDownloadOptions,
type StartDownloadResult,
@@ -64,10 +67,34 @@ const api = {
getYtdlpVersion: (): Promise<YtdlpVersionResult> => ipcRenderer.invoke(IpcChannels.ytdlpVersion),
/** Versions of the bundled ffmpeg + ffprobe (display-only; not auto-updated). */
/** Versions of the managed ffmpeg + ffprobe (display-only; not auto-updated). */
getFfmpegVersions: (): Promise<FfmpegVersionResult> =>
ipcRenderer.invoke(IpcChannels.ffmpegVersion),
/** Readiness (both binaries present) + versions of the managed ffmpeg/ffprobe. */
getFfmpegSetupStatus: (): Promise<FfmpegSetupStatus> =>
ipcRenderer.invoke(IpcChannels.ffmpegSetupStatus),
/** Download + unpack ffmpeg/ffprobe from the pinned upstream archive (first-run setup). */
downloadFfmpeg: (): Promise<FfmpegSetupResult> =>
ipcRenderer.invoke(IpcChannels.ffmpegSetupDownload),
/** Abort the in-flight ffmpeg archive download (the setup card's Cancel). */
cancelFfmpegDownload: (): Promise<void> => ipcRenderer.invoke(IpcChannels.ffmpegSetupCancel),
/** Offline fallback: pick a folder holding ffmpeg.exe + ffprobe.exe and install from it. */
locateFfmpeg: (): Promise<FfmpegSetupResult> => ipcRenderer.invoke(IpcChannels.ffmpegSetupLocate),
/** Subscribe to ffmpeg setup progress (download %, then extract). Returns an unsubscribe fn. */
onFfmpegSetupProgress: (cb: (p: FfmpegSetupProgress) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, p: FfmpegSetupProgress): void => cb(p)
ipcRenderer.on(IpcChannels.ffmpegSetupProgress, listener)
return () => ipcRenderer.removeListener(IpcChannels.ffmpegSetupProgress, listener)
},
/** Whether the optional aria2c external downloader is present in this build. */
aria2cAvailable: (): Promise<boolean> => ipcRenderer.invoke(IpcChannels.aria2cAvailable),
probe: (url: string): Promise<ProbeResult> => ipcRenderer.invoke(IpcChannels.probe, url),
startDownload: (opts: StartDownloadOptions): Promise<StartDownloadResult> =>
+12 -1
View File
@@ -8,6 +8,7 @@ import { LiveRegion } from './components/LiveRegion'
import { Toaster } from './components/ui/Toaster'
import { getTheme, pageBackground } from './lib/theme'
import { useSettings } from './store/settings'
import { useSetup } from './store/setup'
import { useNav } from './store/nav'
import { useDownloads } from './store/downloads'
// Eagerly load the sources store for its startup side-effects (load persisted
@@ -130,6 +131,11 @@ function App(): React.JSX.Element {
useDownloads.getState().hydrate()
}, [])
// Subscribe to ffmpeg setup progress and run the initial readiness check, which drives
// the first-run setup gate below. In an App effect (not at store module load) so it runs
// after window.api is assigned in every context (real bridge and browser-preview mock).
useEffect(() => useSetup.getState().init(), [])
// UX16: focus the URL field on launch so the user can paste + type immediately
// (Downloads is the default tab). Double-rAF waits for paint + the DownloadBar to
// register its focuser (L118), matching the command palette's "New download" focus.
@@ -222,7 +228,12 @@ function App(): React.JSX.Element {
// by a one-frame flash of the (default-false) onboarding state.
const loaded = useSettings((s) => s.loaded)
const hasCompletedOnboarding = useSettings((s) => s.hasCompletedOnboarding)
const showOnboarding = loaded && !hasCompletedOnboarding
// Onboarding doubles as the one-time media-tools setup: show it for a fresh user, OR
// whenever ffmpeg/ffprobe are confirmed missing (an existing user upgrading from a
// bundled build). `=== false` (not falsy) so the null "still checking" state never
// flashes the setup screen at a user who actually has ffmpeg.
const ffmpegReady = useSetup((s) => s.ffmpegReady)
const showOnboarding = loaded && (!hasCompletedOnboarding || ffmpegReady === false)
return (
<FluentProvider
+49 -3
View File
@@ -2,7 +2,8 @@ import {
DEFAULT_SETTINGS,
type Settings,
type CommandTemplate,
type MediaProfile
type MediaProfile,
type FfmpegSetupProgress
} from '@shared/ipc'
/**
@@ -50,6 +51,11 @@ let mockProfiles: MediaProfile[] = [
{ id: 'p2', name: 'Music (audio only)', kind: 'audio', quality: 'Best' }
]
// First-run ffmpeg setup preview state: ready unless `?setup` is in the preview URL (which
// forces the onboarding setup step), plus the progress callback the fake download drives.
let mockFfmpegReady = typeof location === 'undefined' || !/[?&]setup(\b|=)/.test(location.search)
let mockFfmpegProgressCb: ((p: FfmpegSetupProgress) => void) | null = null
export const mockApi: Api = {
getAppVersion: async () => MOCK_CURRENT_VERSION,
checkForAppUpdate: async () => {
@@ -75,9 +81,46 @@ export const mockApi: Api = {
onAppUpdateProgress: () => () => {},
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
getFfmpegVersions: async () => ({
ffmpeg: '7.1-full_build (UI preview mock)',
ffprobe: '7.1-full_build (UI preview mock)'
ffmpeg: '8.1.2-essentials_build (UI preview mock)',
ffprobe: '8.1.2-essentials_build (UI preview mock)'
}),
// First-run ffmpeg setup: ready by default so the preview isn't blocked. Append `?setup`
// to the preview URL to force the not-ready state and demo the onboarding setup step —
// the fake download/locate below then flips it ready.
getFfmpegSetupStatus: async () =>
mockFfmpegReady
? {
ready: true,
ffmpeg: '8.1.2-essentials_build (mock)',
ffprobe: '8.1.2-essentials_build (mock)'
}
: { ready: false, ffmpeg: null, ffprobe: null },
downloadFfmpeg: async () => {
for (let i = 1; i <= 10; i++) {
await new Promise((r) => setTimeout(r, 150))
mockFfmpegProgressCb?.({
phase: 'downloading',
received: i * 11_000_000,
total: 110_000_000,
fraction: i / 10
})
}
mockFfmpegProgressCb?.({ phase: 'extracting' })
await new Promise((r) => setTimeout(r, 500))
mockFfmpegReady = true
return { ok: true }
},
cancelFfmpegDownload: async () => {},
locateFfmpeg: async () => {
mockFfmpegReady = true
return { ok: true }
},
onFfmpegSetupProgress: (cb) => {
mockFfmpegProgressCb = cb
return () => {
if (mockFfmpegProgressCb === cb) mockFfmpegProgressCb = null
}
},
probe: async (url: string) => {
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
// A 'list'/'playlist' URL exercises the playlist selection UI in preview.
@@ -167,6 +210,9 @@ export const mockApi: Api = {
mockCookiesSavedAt = Date.now()
return { ok: true, cookieCount: 14 }
},
// Preview assumes the bundled aria2c is present, so the Network toggle renders
// in its normal (enabled) state.
aria2cAvailable: async () => true,
cookiesStatus: async () =>
mockCookiesSavedAt ? { exists: true, savedAt: mockCookiesSavedAt } : { exists: false },
cookiesClear: async () => {
+112
View File
@@ -0,0 +1,112 @@
import { create } from 'zustand'
import { logError } from '../reportError'
/**
* First-run setup state: whether the managed ffmpeg/ffprobe are present, and the
* download / locate orchestration for acquiring them. The App gate blocks the app on
* `ffmpegReady === false`, and Onboarding + the About card drive the actions here.
*
* Unlike the settings store, this has no preview-only fallback: `window.api` is the real
* bridge or the mockApi (which simulates a fake download and honours the `?setup` preview
* flag), so the same code path works in both. Init is driven from an App effect not at
* module load so it runs after `window.api` is assigned in every context.
*/
interface SetupState {
/** null until the first status check resolves; then true once both binaries are present */
ffmpegReady: boolean | null
ffmpegVersion: string | null
ffprobeVersion: string | null
/** phase of an in-flight acquisition (drives the progress UI) */
phase: 'idle' | 'downloading' | 'extracting'
/** 0..1 download fraction when known, else undefined (indeterminate bar) */
fraction: number | undefined
/** a download or locate is running */
busy: boolean
error: string | null
/** subscribe to progress + run the first status check; returns an unsubscribe fn */
init: () => () => void
/** re-query readiness + versions from main */
check: () => Promise<void>
/** download + unpack ffmpeg from the pinned upstream archive */
download: () => Promise<void>
/** abort the in-flight download */
cancel: () => void
/** pick a folder that already holds ffmpeg + ffprobe and install from it */
locate: () => Promise<void>
}
// Set when the user cancels a download, so the (not-ok) awaited result is treated as a
// cancel rather than surfaced as an error — same idea as the app-update card's canceledRef.
let canceled = false
export const useSetup = create<SetupState>((set, get) => ({
ffmpegReady: null,
ffmpegVersion: null,
ffprobeVersion: null,
phase: 'idle',
fraction: undefined,
busy: false,
error: null,
init: () => {
void get().check()
return (
window.api?.onFfmpegSetupProgress?.((p) =>
set({ phase: p.phase, fraction: p.phase === 'downloading' ? p.fraction : undefined })
) ?? (() => {})
)
},
check: async () => {
try {
const s = await window.api.getFfmpegSetupStatus()
set({ ffmpegReady: s.ready, ffmpegVersion: s.ffmpeg, ffprobeVersion: s.ffprobe })
} catch (e) {
logError('getFfmpegSetupStatus')(e)
}
},
download: async () => {
if (get().busy) return
canceled = false
set({ busy: true, error: null, phase: 'downloading', fraction: undefined })
try {
const r = await window.api.downloadFfmpeg()
// The user hit Cancel: main aborted the download and cleaned up — don't surface its
// not-ok result as an error.
if (canceled) return
if (!r.ok) {
set({ error: r.error ?? 'The ffmpeg download failed.' })
return
}
await get().check() // flips ffmpegReady true, clearing the gate
} catch (e) {
if (!canceled) set({ error: e instanceof Error ? e.message : String(e) })
} finally {
set({ busy: false, phase: 'idle', fraction: undefined })
}
},
cancel: () => {
canceled = true
window.api.cancelFfmpegDownload?.().catch(logError('cancelFfmpegDownload'))
},
locate: async () => {
if (get().busy) return
set({ busy: true, error: null })
try {
const r = await window.api.locateFfmpeg()
if (!r.ok) {
// A dismissed folder picker ('No folder selected.') isn't an error worth showing.
if (r.error && !/no folder selected/i.test(r.error)) set({ error: r.error })
return
}
await get().check()
} catch (e) {
set({ error: e instanceof Error ? e.message : String(e) })
} finally {
set({ busy: false })
}
}
}))
+130 -4
View File
@@ -5,6 +5,8 @@ import {
Caption1,
Field,
Button,
Spinner,
ProgressBar,
makeStyles,
mergeClasses,
tokens,
@@ -12,6 +14,9 @@ import {
} from '@fluentui/react-components'
import {
ArrowDownloadFilled,
ArrowDownloadRegular,
DismissRegular,
CheckmarkCircleFilled,
ClipboardPasteRegular,
HistoryRegular,
OptionsRegular,
@@ -22,6 +27,8 @@ import {
KeyboardRegular
} from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
import { useSetup } from '../store/setup'
import { useErrorTextStyles } from '../components/ui/errorText'
import { SPACE, ICON } from '../components/ui/tokens'
const useStyles = makeStyles({
@@ -99,6 +106,31 @@ const useStyles = makeStyles({
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
// Media-components setup box (download/locate ffmpeg on first run).
setupBox: {
display: 'flex',
flexDirection: 'column',
gap: '10px',
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusMedium),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
setupRow: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
setupButtons: {
display: 'flex',
flexWrap: 'wrap',
gap: '8px'
},
setupReadyIcon: {
fontSize: `${ICON.control}px`,
flexShrink: 0,
color: tokens.colorStatusSuccessForeground1
},
tips: {
display: 'flex',
flexDirection: 'column',
@@ -138,10 +170,88 @@ const TIPS: { icon: React.JSX.Element; text: string }[] = [
export function Onboarding(): React.JSX.Element {
const styles = useStyles()
const errText = useErrorTextStyles()
const update = useSettings((s) => s.update)
const videoFolder = useSettings((s) => s.videoFolder)
const audioFolder = useSettings((s) => s.audioFolder)
const chooseFolder = useSettings((s) => s.chooseFolder)
// A brand-new user gets the full welcome; an already-onboarded user only lands here
// because ffmpeg is missing (e.g. upgrading from a bundled build) — show a compact
// "finish setup" card that auto-dismisses once the tools are installed.
const isFresh = !useSettings((s) => s.hasCompletedOnboarding)
const ready = useSetup((s) => s.ffmpegReady)
const phase = useSetup((s) => s.phase)
const fraction = useSetup((s) => s.fraction)
const busy = useSetup((s) => s.busy)
const setupError = useSetup((s) => s.error)
const download = useSetup((s) => s.download)
const cancel = useSetup((s) => s.cancel)
const locate = useSetup((s) => s.locate)
const componentsField = (
<Field label="Media components">
<div className={styles.setupBox}>
{ready === true ? (
<div className={styles.setupRow}>
<CheckmarkCircleFilled className={styles.setupReadyIcon} />
<Caption1>ffmpeg and ffprobe are installed and ready.</Caption1>
</div>
) : ready === null ? (
<div className={styles.setupRow}>
<Spinner size="tiny" />
<Caption1>Checking media components</Caption1>
</div>
) : (
<>
<Caption1 className={styles.folderNote}>
AeroFetch uses ffmpeg to merge and convert media. It isn&apos;t bundled download it
once (about 105 MB) and it&apos;s kept across updates. On an offline machine, point
AeroFetch at an ffmpeg you already have.
</Caption1>
{busy ? (
<>
<ProgressBar
value={phase === 'extracting' ? undefined : fraction}
aria-label="ffmpeg setup progress"
/>
<div className={styles.setupRow}>
<Caption1 className={styles.folderNote}>
{phase === 'extracting' ? 'Unpacking…' : 'Downloading ffmpeg…'}
</Caption1>
{/* Only the download is cancelable — unpacking runs to completion. */}
{phase === 'downloading' && (
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={cancel}
>
Cancel
</Button>
)}
</div>
</>
) : (
<div className={styles.setupButtons}>
<Button
appearance="primary"
icon={<ArrowDownloadRegular />}
onClick={() => void download()}
>
Download components
</Button>
<Button icon={<FolderRegular />} onClick={() => void locate()}>
Locate existing ffmpeg
</Button>
</div>
)}
{setupError && <Caption1 className={errText.errorPre}>{setupError}</Caption1>}
</>
)}
</div>
</Field>
)
return (
<div className={styles.root}>
@@ -150,18 +260,26 @@ export function Onboarding(): React.JSX.Element {
<div className={styles.mark}>
<ArrowDownloadFilled />
</div>
<Title2 as="h1">Welcome to AeroFetch</Title2>
<Title2 as="h1">{isFresh ? 'Welcome to AeroFetch' : 'Finish setup'}</Title2>
</div>
{isFresh ? (
<Body1>
A no-frills frontend for yt-dlp: paste a link, pick a quality, and download video or
audio. yt-dlp and ffmpeg are bundled, so there&apos;s nothing else to install.
audio. yt-dlp is bundled; ffmpeg is set up once below.
</Body1>
) : (
<Body1>
AeroFetch needs ffmpeg and ffprobe to process downloads. They aren&apos;t bundled set
them up once to continue.
</Body1>
)}
{isFresh && (
<Field label="Where downloads go">
<Caption1 className={styles.folderNote}>
Videos and audio save to your Documents folders by default. Pick a different folder now,
or change it any time in Settings.
Videos and audio save to your Documents folders by default. Pick a different folder
now, or change it any time in Settings.
</Caption1>
<div className={styles.folderRow}>
<VideoClipRegular className={styles.folderIcon} />
@@ -196,7 +314,12 @@ export function Onboarding(): React.JSX.Element {
</Button>
</div>
</Field>
)}
{componentsField}
{isFresh && (
<>
<div className={styles.tips}>
{TIPS.map((tip) => (
<div key={tip.text} className={styles.tip}>
@@ -209,10 +332,13 @@ export function Onboarding(): React.JSX.Element {
<Button
appearance="primary"
icon={<RocketRegular />}
disabled={ready !== true}
onClick={() => update({ hasCompletedOnboarding: true })}
>
Get started
</Button>
</>
)}
</Card>
</div>
)
+65 -3
View File
@@ -7,12 +7,20 @@ import {
Caption1,
Text,
Spinner,
ProgressBar,
Skeleton,
SkeletonItem
} from '@fluentui/react-components'
import { InfoRegular, ArrowSyncRegular } from '@fluentui/react-icons'
import {
InfoRegular,
ArrowSyncRegular,
ArrowDownloadRegular,
DismissRegular,
FolderRegular
} from '@fluentui/react-icons'
import { type YtdlpUpdateChannel } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { useSetup } from '../../store/setup'
import { Select } from '../../components/Select'
import { useErrorTextStyles } from '../../components/ui/errorText'
import { useSettingsStyles } from './settingsStyles'
@@ -43,6 +51,25 @@ export function AboutCard(): React.JSX.Element {
refreshFfmpeg
} = useAboutCard()
// ffmpeg is fetched on first run (not bundled); the setup store owns its download/locate
// orchestration + progress. Re-read the displayed versions once an action settles.
const ffmpegReady = useSetup((s) => s.ffmpegReady)
const setupBusy = useSetup((s) => s.busy)
const setupPhase = useSetup((s) => s.phase)
const setupFraction = useSetup((s) => s.fraction)
const setupError = useSetup((s) => s.error)
const downloadFfmpeg = useSetup((s) => s.download)
const cancelFfmpeg = useSetup((s) => s.cancel)
const locateFfmpeg = useSetup((s) => s.locate)
async function getFfmpeg(): Promise<void> {
await downloadFfmpeg()
refreshFfmpeg()
}
async function locate(): Promise<void> {
await locateFfmpeg()
refreshFfmpeg()
}
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
@@ -50,8 +77,8 @@ export function AboutCard(): React.JSX.Element {
<Subtitle2 as="h3">About</Subtitle2>
</div>
<Caption1 className={styles.hint}>
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its own copy of
yt-dlp, keeping it up to date automatically.
AeroFetch is a generic frontend for yt-dlp. It manages its own copy of yt-dlp (keeping it up
to date automatically) and downloads ffmpeg on first run.
</Caption1>
<div className={styles.folderRow}>
{/* Re-show the first-run welcome/tips screen on demand (UX22). */}
@@ -98,6 +125,41 @@ export function AboutCard(): React.JSX.Element {
/>
</div>
)}
{ffmpeg &&
(setupBusy ? (
<>
<ProgressBar
value={setupPhase === 'extracting' ? undefined : setupFraction}
aria-label="ffmpeg setup progress"
/>
<div className={styles.folderRow}>
<Caption1 className={styles.hint}>
{setupPhase === 'extracting' ? 'Unpacking…' : 'Downloading ffmpeg…'}
</Caption1>
{/* Only the download is cancelable — unpacking runs to completion. */}
{setupPhase === 'downloading' && (
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={cancelFfmpeg}
>
Cancel
</Button>
)}
</div>
</>
) : (
<div className={styles.folderRow}>
<Button icon={<ArrowDownloadRegular />} onClick={() => void getFfmpeg()}>
{ffmpegReady === true ? 'Repair ffmpeg' : 'Download ffmpeg'}
</Button>
<Button icon={<FolderRegular />} onClick={() => void locate()}>
Locate existing
</Button>
</div>
))}
{setupError && <Caption1 className={errText.errorPre}>{setupError}</Caption1>}
{ytdlpLastUpdateCheck > 0 && (
<Caption1 className={styles.hint}>
Last checked for updates {new Date(ytdlpLastUpdateCheck).toLocaleString()}.
@@ -26,8 +26,12 @@ export function NetworkCard(): React.JSX.Element {
const youtubePoToken = useSettings((s) => s.youtubePoToken)
const update = useSettings((s) => s.update)
// View-model hook owns the PO-token minting state + IPC (L93/CC13).
const { mintingPot, potHint, clearPotHint, mintPoToken } = useNetworkCard()
// View-model hook owns the PO-token minting state + IPC (L93/CC13), plus the
// aria2c-presence probe that keeps the toggle below honest.
const { mintingPot, potHint, clearPotHint, mintPoToken, aria2cAvailable } = useNetworkCard()
// Only treat aria2c as missing once the probe has actually resolved false, so a
// slow check doesn't briefly disable a working toggle.
const aria2cMissing = aria2cAvailable === false
return (
<Card className={styles.card}>
@@ -114,11 +118,21 @@ export function NetworkCard(): React.JSX.Element {
<Field
label="Use aria2c downloader"
hint="Multi-connection downloads for faster speeds on supported sites. Falls back to the standard downloader if the accelerator isn't available."
validationState={aria2cMissing ? 'warning' : 'none'}
validationMessage={
aria2cMissing
? "aria2c isn't included in this build, so this setting has no effect."
: undefined
}
>
<Switch checked={useAria2c} onChange={(_, d) => update({ useAria2c: d.checked })} />
<Switch
checked={useAria2c}
disabled={aria2cMissing}
onChange={(_, d) => update({ useAria2c: d.checked })}
/>
</Field>
{useAria2c && (
{useAria2c && !aria2cMissing && (
<Field
label="aria2c connections"
hint="Parallel connections per download (116). More can be faster on throttled connections; fewer is gentler on servers."
@@ -1,6 +1,4 @@
import {
Field,
Input,
Button,
Card,
Subtitle2,
@@ -16,7 +14,6 @@ import {
OpenRegular,
DismissRegular
} from '@fluentui/react-icons'
import { useSettings } from '../../store/settings'
import { useErrorTextStyles } from '../../components/ui/errorText'
import { useSettingsStyles } from './settingsStyles'
import { useSoftwareUpdateCard } from './useSoftwareUpdateCard'
@@ -24,8 +21,6 @@ import { useSoftwareUpdateCard } from './useSoftwareUpdateCard'
export function SoftwareUpdateCard(): React.JSX.Element {
const styles = useSettingsStyles()
const errText = useErrorTextStyles()
const updateToken = useSettings((s) => s.updateToken)
const update = useSettings((s) => s.update)
// View-model hook owns the check → download → install orchestration (L93/CC13).
const {
@@ -63,21 +58,6 @@ export function SoftwareUpdateCard(): React.JSX.Element {
</Button>
</div>
{(updateToken.trim() !== '' || !!appUpdError) && (
<Field
label="Update access token"
hint="Only needed if the update server requires sign-in. Paste a read-only access token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access."
>
<Input
type="password"
value={updateToken}
placeholder="Optional -- access token"
onChange={(_, d) => update({ updateToken: d.value })}
contentBefore={<ArrowSyncRegular />}
/>
</Field>
)}
{appUpd?.ok && appUpd.available && (
<>
<Text style={{ fontWeight: tokens.fontWeightSemibold }}>
@@ -38,7 +38,7 @@ export function useAboutCard(): AboutCardController {
.then(setVersion)
.catch(logError('getYtdlpVersion'))
.finally(() => setChecking(false))
// ffmpeg/ffprobe are display-only (bundled, not auto-updated); load them once.
// ffmpeg/ffprobe are display-only (fetched on first run, not auto-updated); load them once.
// On a bridge failure, settle to "not found" rather than a permanent skeleton.
window.api
.getFfmpegVersions()
@@ -1,19 +1,33 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { logError } from '../../reportError'
export interface NetworkCardController {
mintingPot: boolean
potHint: string | null
clearPotHint: () => void
mintPoToken: () => Promise<void>
/** whether aria2c.exe shipped with this build; null while the check is in flight */
aria2cAvailable: boolean | null
}
/**
* View-model for the Network card's PO-token minting (L93/CC13): owns the fetch
* state and hint copy so the card stays presentational.
* state and hint copy so the card stays presentational. Also probes whether the
* optional aria2c downloader is bundled, so the card can disable its toggle rather
* than leave a setting that silently does nothing.
*/
export function useNetworkCard(): NetworkCardController {
const [mintingPot, setMintingPot] = useState(false)
const [potHint, setPotHint] = useState<string | null>(null)
const [aria2cAvailable, setAria2cAvailable] = useState<boolean | null>(null)
useEffect(() => {
// Detect whether the optional aria2c downloader is bundled so the toggle can
// disable itself instead of silently doing nothing. A failed probe leaves the
// state null (unknown), which keeps the toggle enabled rather than wrongly
// disabling a working feature.
window.api.aria2cAvailable().then(setAria2cAvailable).catch(logError('aria2cAvailable'))
}, [])
async function mintPoToken(): Promise<void> {
setMintingPot(true)
@@ -32,5 +46,5 @@ export function useNetworkCard(): NetworkCardController {
}
}
return { mintingPot, potHint, clearPotHint: () => setPotHint(null), mintPoToken }
return { mintingPot, potHint, clearPotHint: () => setPotHint(null), mintPoToken, aria2cAvailable }
}
+42 -11
View File
@@ -18,6 +18,18 @@ export const IpcChannels = {
appUpdateProgress: 'app:update-progress',
ytdlpVersion: 'ytdlp:version',
ffmpegVersion: 'ffmpeg:version',
/** readiness (both binaries present) + versions of the managed ffmpeg/ffprobe */
ffmpegSetupStatus: 'ffmpeg:setup-status',
/** download + unpack ffmpeg/ffprobe from the pinned upstream archive (first-run setup) */
ffmpegSetupDownload: 'ffmpeg:setup-download',
/** abort the in-flight ffmpeg archive download */
ffmpegSetupCancel: 'ffmpeg:setup-cancel',
/** pick a folder that already holds ffmpeg.exe + ffprobe.exe and install from it */
ffmpegSetupLocate: 'ffmpeg:setup-locate',
/** main → renderer push channel for ffmpeg setup progress (download %, then extract) */
ffmpegSetupProgress: 'ffmpeg:setup-progress',
/** whether the optional aria2c.exe external downloader is present in the bin dir */
aria2cAvailable: 'aria2c:available',
probe: 'media:probe',
downloadStart: 'download:start',
downloadCancel: 'download:cancel',
@@ -275,9 +287,9 @@ export interface YtdlpVersionResult {
}
/**
* Versions of the bundled ffmpeg + ffprobe, for display in Settings. Each is the
* Versions of the managed ffmpeg + ffprobe, for display in Settings. Each is the
* parsed version token, or null when that binary is missing or unreadable. These
* ship with AeroFetch and aren't auto-updated (ffmpeg has no self-update and,
* are fetched on first run (not bundled) and aren't auto-updated (ffmpeg has no self-update and,
* unlike yt-dlp, doesn't break as sites change), so there's no ok/error here
* just "what's installed".
*/
@@ -286,6 +298,33 @@ export interface FfmpegVersionResult {
ffprobe: string | null
}
/** Readiness of the managed ffmpeg/ffprobe: `ready` gates the first-run setup screen. */
export interface FfmpegSetupStatus {
/** true once both ffmpeg.exe and ffprobe.exe are present in the managed dir */
ready: boolean
/** installed versions (null when the binary is absent), for the About card */
ffmpeg: string | null
ffprobe: string | null
}
/** Live progress of the first-run ffmpeg acquisition (main → renderer). */
export interface FfmpegSetupProgress {
/** 'downloading' the archive, then a brief indeterminate 'extracting' phase */
phase: 'downloading' | 'extracting'
/** bytes received so far (downloading phase) */
received?: number
/** total archive bytes, when Content-Length is known */
total?: number
/** 0..1 download fraction, when total is known */
fraction?: number
}
/** Result of a download / locate setup action. */
export interface FfmpegSetupResult {
ok: boolean
error?: string
}
/** Result of checking the configured Gitea repo for a newer AeroFetch release. */
export interface AppUpdateInfo {
ok: boolean
@@ -684,13 +723,6 @@ export interface Settings {
* modern GPUs can turn this on for smoother high-DPI/large-window rendering.
*/
useHardwareAcceleration: boolean
/**
* Optional Gitea access token for the in-app updater. Empty = anonymous (works
* only where the release repo allows anonymous access). When the release repo
* is private / the instance requires sign-in, paste a read-only token so the
* updater can check + download. Stored locally in settings, never shipped.
*/
updateToken: string
}
/**
@@ -753,8 +785,7 @@ export const DEFAULT_SETTINGS: Settings = {
minimizeToTray: false,
launchAtStartup: false,
isSidebarCollapsed: false,
useHardwareAcceleration: false,
updateToken: ''
useHardwareAcceleration: false
}
/**
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest'
import { existsSync, statSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
// Regression guard: the `useAria2c` setting was a silent no-op because aria2c.exe
// was never bundled — download.ts gates the --downloader flag on the file existing
// (existsSync(getAria2cPath())), so with no binary the toggle did nothing. This
// asserts the binary ships in resources/bin (where getBinDir/getAria2cPath resolve
// it, and where electron-builder's extraResources copies it into packaged builds).
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
const aria2cPath = join(repoRoot, 'resources', 'bin', 'aria2c.exe')
describe('bundled aria2c', () => {
it('ships aria2c.exe in resources/bin', () => {
expect(existsSync(aria2cPath)).toBe(true)
})
it('is a real executable, not an empty or git-lfs pointer placeholder', () => {
// The real binary is ~5.6 MB; a pointer/placeholder would be a few hundred
// bytes. 1 MB is a comfortable floor that catches either failure mode.
expect(statSync(aria2cPath).size).toBeGreaterThan(1_000_000)
})
})
+224
View File
@@ -0,0 +1,224 @@
/**
* Tests for the first-run ffmpeg acquisition (ffmpegSetup.ts). The shared streaming +
* checksum loop is mocked here it's already pinned by updaterDownload.test.ts so these
* focus on the ffmpeg-specific behaviour: the upstream trust gate, readiness + dev seed,
* the download tar-extract install-into-managed-dir flow, and the manual-locate
* fallback (accept a valid folder, reject a missing/blocked one).
*/
import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest'
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { WebContents } from 'electron'
const ROOT = mkdtempSync(join(tmpdir(), 'aerofetch-ffmpeg-test-'))
const USERDATA = join(ROOT, 'userData')
const TEMP = join(ROOT, 'temp')
const RES = join(ROOT, 'resources')
const MANAGED = join(USERDATA, 'bin')
// getBinDir() resolves the dev seed under <getAppPath()>/resources/bin (its is.dev branch),
// so mocking is.dev=true + app.getAppPath()=ROOT points it at RES/bin. Deliberately NOT
// mutating the process-global process.resourcesPath, which would leak across test files.
// Shared, per-test-tunable control surface (hoisted so the vi.mock factories can read it).
const h = vi.hoisted(() => ({
streamOk: true,
streamError: 'network boom',
tarFail: false,
extractWrites: true,
validateOk: true,
dialog: { canceled: false, filePaths: [] as string[] }
}))
vi.mock('@electron-toolkit/utils', () => ({ is: { dev: true } }))
vi.mock('electron', () => ({
app: {
getPath: (k: string) => (k === 'userData' ? join(ROOT, 'userData') : join(ROOT, 'temp')),
getAppPath: () => ROOT
},
dialog: { showOpenDialog: async () => h.dialog },
BrowserWindow: class {}
}))
vi.mock('../src/main/logger', () => ({
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }
}))
// ffmpeg version read is display-only; stub it so readiness comes purely from file presence.
vi.mock('../src/main/ffmpeg', () => ({
getFfmpegVersions: async () => ({ ffmpeg: 'mock', ffprobe: 'mock' })
}))
// The streamer is pinned elsewhere: simulate a written archive + one progress tick.
vi.mock('../src/main/lib/verifiedDownload', () => ({
streamVerifiedFile: async (opts: {
filePath: string
onProgress: (p: { received: number; total: number; fraction: number }) => void
}) => {
if (!h.streamOk) return { ok: false, error: h.streamError }
writeFileSync(opts.filePath, 'FAKE-ZIP')
opts.onProgress({ received: 100, total: 100, fraction: 1 })
return { ok: true, filePath: opts.filePath }
}
}))
// tar.exe extract + `<tool> -version` validation both go through execFile.
vi.mock('child_process', () => ({
execFile: (
file: string,
args: string[],
_opts: unknown,
cb: (err: Error | null, stdout?: string) => void
) => {
if (file.endsWith('tar.exe')) {
if (h.tarFail) return cb(new Error('tar failed'))
const dest = args[args.indexOf('-C') + 1]
if (h.extractWrites) {
writeFileSync(join(dest, 'ffmpeg.exe'), 'FF')
writeFileSync(join(dest, 'ffprobe.exe'), 'FP')
}
return cb(null)
}
if (args.includes('-version')) {
return h.validateOk ? cb(null, 'ffmpeg version 8.1.2') : cb(new Error('blocked'))
}
return cb(null)
}
}))
const { isTrustedFfmpegUrl, getFfmpegSetupStatus, downloadFfmpeg, locateFfmpegManually } =
await import('../src/main/ffmpegSetup')
function fakeWc(): { wc: WebContents; sends: unknown[] } {
const sends: unknown[] = []
const wc = {
isDestroyed: () => false,
send: (_ch: string, p: unknown) => sends.push(p)
} as unknown as WebContents
return { wc, sends }
}
beforeEach(() => {
rmSync(MANAGED, { recursive: true, force: true })
rmSync(join(RES, 'bin'), { recursive: true, force: true })
mkdirSync(TEMP, { recursive: true })
Object.assign(h, {
streamOk: true,
streamError: 'network boom',
tarFail: false,
extractWrites: true,
validateOk: true,
dialog: { canceled: false, filePaths: [] }
})
})
afterAll(() => rmSync(ROOT, { recursive: true, force: true }))
describe('isTrustedFfmpegUrl', () => {
it('accepts HTTPS on an allowlisted upstream host', () => {
expect(
isTrustedFfmpegUrl('https://github.com/GyanD/codexffmpeg/releases/download/x/f.zip')
).toBe(true)
expect(isTrustedFfmpegUrl('https://release-assets.githubusercontent.com/abc?x=1')).toBe(true)
})
it('rejects http, an off-list host, and garbage', () => {
expect(isTrustedFfmpegUrl('http://github.com/x.zip')).toBe(false)
expect(isTrustedFfmpegUrl('https://evil.example/x.zip')).toBe(false)
expect(isTrustedFfmpegUrl('not a url')).toBe(false)
})
})
describe('getFfmpegSetupStatus', () => {
it('is ready only when both binaries are present', async () => {
mkdirSync(MANAGED, { recursive: true })
writeFileSync(join(MANAGED, 'ffmpeg.exe'), 'x')
expect((await getFfmpegSetupStatus()).ready).toBe(false) // ffprobe missing
writeFileSync(join(MANAGED, 'ffprobe.exe'), 'x')
expect((await getFfmpegSetupStatus()).ready).toBe(true)
})
it('seeds the managed copies from the dev bundle when present', async () => {
mkdirSync(join(RES, 'bin'), { recursive: true })
writeFileSync(join(RES, 'bin', 'ffmpeg.exe'), 'seed')
writeFileSync(join(RES, 'bin', 'ffprobe.exe'), 'seed')
const status = await getFfmpegSetupStatus()
expect(status.ready).toBe(true)
expect(existsSync(join(MANAGED, 'ffmpeg.exe'))).toBe(true)
})
})
describe('downloadFfmpeg', () => {
it('downloads, extracts, and installs both binaries into the managed dir', async () => {
const { wc, sends } = fakeWc()
const r = await downloadFfmpeg(wc)
expect(r).toEqual({ ok: true })
expect(existsSync(join(MANAGED, 'ffmpeg.exe'))).toBe(true)
expect(existsSync(join(MANAGED, 'ffprobe.exe'))).toBe(true)
// A downloading tick and the extracting phase both reach the renderer.
expect(sends).toContainEqual(expect.objectContaining({ phase: 'downloading' }))
expect(sends).toContainEqual(expect.objectContaining({ phase: 'extracting' }))
})
it('surfaces a download failure and installs nothing', async () => {
h.streamOk = false
const { wc } = fakeWc()
const r = await downloadFfmpeg(wc)
expect(r.ok).toBe(false)
expect(r.error).toMatch(/network boom/)
expect(existsSync(join(MANAGED, 'ffmpeg.exe'))).toBe(false)
})
it('fails when extraction fails, leaving the managed dir empty', async () => {
h.tarFail = true
const { wc } = fakeWc()
const r = await downloadFfmpeg(wc)
expect(r.ok).toBe(false)
expect(existsSync(join(MANAGED, 'ffmpeg.exe'))).toBe(false)
})
it('fails when the archive lacks the expected binaries', async () => {
h.extractWrites = false
const { wc } = fakeWc()
const r = await downloadFfmpeg(wc)
expect(r.ok).toBe(false)
expect(r.error).toMatch(/missing/i)
})
})
describe('locateFfmpegManually', () => {
function folderWith(files: string[]): string {
const dir = mkdtempSync(join(ROOT, 'located-'))
for (const f of files) writeFileSync(join(dir, f), 'x')
return dir
}
it('installs from a folder that holds both runnable binaries', async () => {
h.dialog = { canceled: false, filePaths: [folderWith(['ffmpeg.exe', 'ffprobe.exe'])] }
const r = await locateFfmpegManually()
expect(r).toEqual({ ok: true })
expect(existsSync(join(MANAGED, 'ffprobe.exe'))).toBe(true)
})
it('rejects a folder missing a binary', async () => {
h.dialog = { canceled: false, filePaths: [folderWith(['ffmpeg.exe'])] }
const r = await locateFfmpegManually()
expect(r.ok).toBe(false)
expect(r.error).toMatch(/doesn't contain/i)
})
it('rejects binaries that will not run', async () => {
h.validateOk = false
h.dialog = { canceled: false, filePaths: [folderWith(['ffmpeg.exe', 'ffprobe.exe'])] }
const r = await locateFfmpegManually()
expect(r.ok).toBe(false)
expect(r.error).toMatch(/could not be run/i)
})
it('reports a dismissed folder picker without installing anything', async () => {
h.dialog = { canceled: true, filePaths: [] }
const r = await locateFfmpegManually()
expect(r.ok).toBe(false)
expect(r.error).toMatch(/no folder selected/i)
})
})
+1 -1
View File
@@ -122,7 +122,7 @@ describe('parseSettingsField (CC9 schema parity)', () => {
it('secrets: plain strings pass through (encryption happens in settings.ts)', () => {
expect(parseSettingsField('proxy', 'socks5://u:p@h:1')).toEqual(ok('socks5://u:p@h:1'))
expect(parseSettingsField('updateToken', ' tok ')).toEqual(ok('tok'))
expect(parseSettingsField('youtubePoToken', 'po_token_value')).toEqual(ok('po_token_value'))
expect(parseSettingsField('proxy', 42)).toEqual(fail)
})
-8
View File
@@ -33,16 +33,12 @@ class FakeResponse extends EventEmitter {
}
class FakeRequest extends EventEmitter {
headers: Record<string, string> = {}
ended = false
aborted = false
redirectsFollowed = 0
constructor(public url: string) {
super()
}
setHeader(k: string, v: string): void {
this.headers[k] = v
}
end(): void {
this.ended = true
}
@@ -72,10 +68,6 @@ vi.mock('electron', () => ({
shell: {}
}))
vi.mock('../src/main/settings', () => ({
getSettings: () => ({ updateToken: '' })
}))
import { downloadAppUpdate, cancelAppUpdate } from '../src/main/updater'
import type { WebContents } from 'electron'