feat(setup): fetch ffmpeg on first run instead of bundling it
Ship a smaller installer that no longer carries ffmpeg.exe/ffprobe.exe (the bulk of its size). On first run they are downloaded from a pinned upstream archive (gyan 8.1.2), verified against a pinned SHA-256, and unpacked with the OS tar.exe into the managed userData/bin dir -- the same place as the self-updating yt-dlp, so they survive app updates and portable re-extraction. A hard onboarding gate blocks the app until they are present, with a "locate existing ffmpeg" folder-picker fallback for offline machines and a Repair action in Settings > About. The updater's streaming/checksum/redirect/idle-timeout download loop is extracted to lib/verifiedDownload.streamVerifiedFile and shared by both the app-installer download and the ffmpeg fetch; the updater's public behaviour and error strings are unchanged (its boundary tests still pass). No new npm dependency: extraction uses the System32 bsdtar resolved by absolute path (audit F3). Note: this commit also carries pre-existing, in-progress aria2c/network and updater-token work that was already uncommitted in the working tree and is entangled with the above in shared files (updater.ts, ipc.ts, preload/index.ts, mockApi.ts, shared/ipc.ts, plus the settings/network files and aria2c.exe). It was not cleanly separable by path, so it is included here rather than split out. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+41
-13
@@ -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
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
|
||||
@@ -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." }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user