diff --git a/.gitignore b/.gitignore index 4753ccb..55f6694 100644 --- a/.gitignore +++ b/.gitignore @@ -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) diff --git a/electron-builder.yml b/electron-builder.yml index 485ff83..069a04a 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -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 /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 /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 # /icon.ico — build/ itself isn't bundled into the app. - from: build/icon.ico diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d2ac481..c94899d 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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') diff --git a/resources/bin/README.md b/resources/bin/README.md index 6d7caee..e703354 100644 --- a/resources/bin/README.md +++ b/resources/bin/README.md @@ -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) diff --git a/resources/bin/aria2c.exe b/resources/bin/aria2c.exe new file mode 100644 index 0000000..5004e10 Binary files /dev/null and b/resources/bin/aria2c.exe differ diff --git a/src/main/binaries.ts b/src/main/binaries.ts index 8b3cde5..5aa13f5 100644 --- a/src/main/binaries.ts +++ b/src/main/binaries.ts @@ -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 `. + * - 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 `. + * + * 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') } diff --git a/src/main/config.ts b/src/main/config.ts index ac3ec8f..0da70c1 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -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 diff --git a/src/main/constants.ts b/src/main/constants.ts index c64867a..9810a10 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -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. */ diff --git a/src/main/core/buildArgs.ts b/src/main/core/buildArgs.ts index 9fdaa49..bdd6236 100644 --- a/src/main/core/buildArgs.ts +++ b/src/main/core/buildArgs.ts @@ -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' diff --git a/src/main/download.ts b/src/main/download.ts index d26373d..af13f18 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -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, diff --git a/src/main/ffmpeg.ts b/src/main/ffmpeg.ts index 6c4a89e..6490ba3 100644 --- a/src/main/ffmpeg.ts +++ b/src/main/ffmpeg.ts @@ -21,7 +21,7 @@ async function readToolVersion(path: string): Promise { 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 { const [ffmpeg, ffprobe] = await Promise.all([ readToolVersion(getFfmpegPath()), diff --git a/src/main/ffmpegSetup.ts b/src/main/ffmpegSetup.ts new file mode 100644 index 0000000..99ebbdb --- /dev/null +++ b/src/main/ffmpegSetup.ts @@ -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 { + // 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 { + 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 + * `/bin/` path so the exes land flat in destDir. + */ +function extractArchive(zip: string, destDir: string): Promise { + 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 ` -version` to confirm a manually-located binary is actually runnable. */ +function validateTool(path: string): Promise { + 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 { + 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." } +} diff --git a/src/main/index.ts b/src/main/index.ts index 9976a99..aba8894 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 2d69f2d..948ce5e 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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) => { diff --git a/src/main/lib/verifiedDownload.ts b/src/main/lib/verifiedDownload.ts new file mode 100644 index 0000000..2a52d1e --- /dev/null +++ b/src/main/lib/verifiedDownload.ts @@ -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 { + 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((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() + }) +} diff --git a/src/main/settings.ts b/src/main/settings.ts index c175a8f..00130b5 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -62,12 +62,12 @@ function getStore(): Store { 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 diff --git a/src/main/settingsSchema.ts b/src/main/settingsSchema.ts index 873ca5a..b32099a 100644 --- a/src/main/settingsSchema.ts +++ b/src/main/settingsSchema.ts @@ -102,9 +102,7 @@ export const SETTINGS_FIELD_SCHEMAS: { [K in keyof Settings]: z.ZodType 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() } /** diff --git a/src/main/updater.ts b/src/main/updater.ts index 824c0d4..5895bc2 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -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 { - 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 { 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 { } } -/** 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 { - const { url, filePath, expectedSha, onProgress, onCancelReady, onSettled } = opts - return new Promise((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 { if (!isTrustedDownloadUrl(url)) { @@ -465,15 +293,17 @@ export async function downloadAppUpdate(wc: WebContents, url: string): Promise { if (!wc.isDestroyed()) wc.send(IpcChannels.appUpdateProgress, progress) }, @@ -482,6 +312,12 @@ export async function downloadAppUpdate(wc: WebContents, url: string): Promise { 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.' } }) } diff --git a/src/preload/index.ts b/src/preload/index.ts index b3fe665..51b978e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => 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 => ipcRenderer.invoke(IpcChannels.ffmpegVersion), + /** Readiness (both binaries present) + versions of the managed ffmpeg/ffprobe. */ + getFfmpegSetupStatus: (): Promise => + ipcRenderer.invoke(IpcChannels.ffmpegSetupStatus), + + /** Download + unpack ffmpeg/ffprobe from the pinned upstream archive (first-run setup). */ + downloadFfmpeg: (): Promise => + ipcRenderer.invoke(IpcChannels.ffmpegSetupDownload), + + /** Abort the in-flight ffmpeg archive download (the setup card's Cancel). */ + cancelFfmpegDownload: (): Promise => ipcRenderer.invoke(IpcChannels.ffmpegSetupCancel), + + /** Offline fallback: pick a folder holding ffmpeg.exe + ffprobe.exe and install from it. */ + locateFfmpeg: (): Promise => 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 => ipcRenderer.invoke(IpcChannels.aria2cAvailable), + probe: (url: string): Promise => ipcRenderer.invoke(IpcChannels.probe, url), startDownload: (opts: StartDownloadOptions): Promise => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 380a3df..f7c555d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 ( 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 () => { diff --git a/src/renderer/src/store/setup.ts b/src/renderer/src/store/setup.ts new file mode 100644 index 0000000..a736135 --- /dev/null +++ b/src/renderer/src/store/setup.ts @@ -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 + /** download + unpack ffmpeg from the pinned upstream archive */ + download: () => Promise + /** abort the in-flight download */ + cancel: () => void + /** pick a folder that already holds ffmpeg + ffprobe and install from it */ + locate: () => Promise +} + +// 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((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 }) + } + } +})) diff --git a/src/renderer/src/views/Onboarding.tsx b/src/renderer/src/views/Onboarding.tsx index 2838059..29243cb 100644 --- a/src/renderer/src/views/Onboarding.tsx +++ b/src/renderer/src/views/Onboarding.tsx @@ -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 = ( + +
+ {ready === true ? ( +
+ + ffmpeg and ffprobe are installed and ready. +
+ ) : ready === null ? ( +
+ + Checking media components… +
+ ) : ( + <> + + AeroFetch uses ffmpeg to merge and convert media. It isn't bundled — download it + once (about 105 MB) and it's kept across updates. On an offline machine, point + AeroFetch at an ffmpeg you already have. + + {busy ? ( + <> + +
+ + {phase === 'extracting' ? 'Unpacking…' : 'Downloading ffmpeg…'} + + {/* Only the download is cancelable — unpacking runs to completion. */} + {phase === 'downloading' && ( + + )} +
+ + ) : ( +
+ + +
+ )} + {setupError && {setupError}} + + )} +
+
+ ) return (
@@ -150,69 +260,85 @@ export function Onboarding(): React.JSX.Element {
- Welcome to AeroFetch + {isFresh ? 'Welcome to AeroFetch' : 'Finish setup'}
- - 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's nothing else to install. - + {isFresh ? ( + + A no-frills frontend for yt-dlp: paste a link, pick a quality, and download video or + audio. yt-dlp is bundled; ffmpeg is set up once below. + + ) : ( + + AeroFetch needs ffmpeg and ffprobe to process downloads. They aren't bundled — set + them up once to continue. + + )} - - - Videos and audio save to your Documents folders by default. Pick a different folder now, - or change it any time in Settings. - -
- -
- Video - - {videoFolder || 'Documents\\Video'} - + {isFresh && ( + + + Videos and audio save to your Documents folders by default. Pick a different folder + now, or change it any time in Settings. + +
+ +
+ Video + + {videoFolder || 'Documents\\Video'} + +
+
+
+ +
+ Audio + + {audioFolder || 'Documents\\Audio'} + +
+ +
+
+ )} + + {componentsField} + + {isFresh && ( + <> +
+ {TIPS.map((tip) => ( +
+ {tip.icon} + {tip.text} +
+ ))} +
+ -
-
- -
- Audio - - {audioFolder || 'Documents\\Audio'} - -
- -
- - -
- {TIPS.map((tip) => ( -
- {tip.icon} - {tip.text} -
- ))} -
- - + + )}
) diff --git a/src/renderer/src/views/settings/AboutCard.tsx b/src/renderer/src/views/settings/AboutCard.tsx index 9391f9f..2304c5a 100644 --- a/src/renderer/src/views/settings/AboutCard.tsx +++ b/src/renderer/src/views/settings/AboutCard.tsx @@ -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 { + await downloadFfmpeg() + refreshFfmpeg() + } + async function locate(): Promise { + await locateFfmpeg() + refreshFfmpeg() + } + return (
@@ -50,8 +77,8 @@ export function AboutCard(): React.JSX.Element { About
- 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.
{/* Re-show the first-run welcome/tips screen on demand (UX22). */} @@ -98,6 +125,41 @@ export function AboutCard(): React.JSX.Element { />
)} + {ffmpeg && + (setupBusy ? ( + <> + +
+ + {setupPhase === 'extracting' ? 'Unpacking…' : 'Downloading ffmpeg…'} + + {/* Only the download is cancelable — unpacking runs to completion. */} + {setupPhase === 'downloading' && ( + + )} +
+ + ) : ( +
+ + +
+ ))} + {setupError && {setupError}} {ytdlpLastUpdateCheck > 0 && ( Last checked for updates {new Date(ytdlpLastUpdateCheck).toLocaleString()}. diff --git a/src/renderer/src/views/settings/NetworkCard.tsx b/src/renderer/src/views/settings/NetworkCard.tsx index 28e533c..39d2837 100644 --- a/src/renderer/src/views/settings/NetworkCard.tsx +++ b/src/renderer/src/views/settings/NetworkCard.tsx @@ -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 ( @@ -114,11 +118,21 @@ export function NetworkCard(): React.JSX.Element { - update({ useAria2c: d.checked })} /> + update({ useAria2c: d.checked })} + /> - {useAria2c && ( + {useAria2c && !aria2cMissing && ( 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 { - {(updateToken.trim() !== '' || !!appUpdError) && ( - - update({ updateToken: d.value })} - contentBefore={} - /> - - )} - {appUpd?.ok && appUpd.available && ( <> diff --git a/src/renderer/src/views/settings/useAboutCard.ts b/src/renderer/src/views/settings/useAboutCard.ts index df10bc7..fb2fa46 100644 --- a/src/renderer/src/views/settings/useAboutCard.ts +++ b/src/renderer/src/views/settings/useAboutCard.ts @@ -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() diff --git a/src/renderer/src/views/settings/useNetworkCard.ts b/src/renderer/src/views/settings/useNetworkCard.ts index ae98225..18fbe8d 100644 --- a/src/renderer/src/views/settings/useNetworkCard.ts +++ b/src/renderer/src/views/settings/useNetworkCard.ts @@ -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 + /** 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(null) + const [aria2cAvailable, setAria2cAvailable] = useState(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 { 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 } } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 77b3ee6..39f7a0c 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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 } /** diff --git a/test/aria2cBundled.test.ts b/test/aria2cBundled.test.ts new file mode 100644 index 0000000..b6aa009 --- /dev/null +++ b/test/aria2cBundled.test.ts @@ -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) + }) +}) diff --git a/test/ffmpegSetup.test.ts b/test/ffmpegSetup.test.ts new file mode 100644 index 0000000..dca755e --- /dev/null +++ b/test/ffmpegSetup.test.ts @@ -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 /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 + ` -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) + }) +}) diff --git a/test/settingsSchema.test.ts b/test/settingsSchema.test.ts index 11ffb2d..31fd8ca 100644 --- a/test/settingsSchema.test.ts +++ b/test/settingsSchema.test.ts @@ -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) }) diff --git a/test/updaterDownload.test.ts b/test/updaterDownload.test.ts index ffc7e8c..060150d 100644 --- a/test/updaterDownload.test.ts +++ b/test/updaterDownload.test.ts @@ -33,16 +33,12 @@ class FakeResponse extends EventEmitter { } class FakeRequest extends EventEmitter { - headers: Record = {} 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'