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:
2026-07-07 11:02:13 -04:00
parent cb25262a2d
commit eb53de2ea5
33 changed files with 1391 additions and 386 deletions
+224
View File
@@ -0,0 +1,224 @@
/**
* Tests for the first-run ffmpeg acquisition (ffmpegSetup.ts). The shared streaming +
* checksum loop is mocked here — it's already pinned by updaterDownload.test.ts — so these
* focus on the ffmpeg-specific behaviour: the upstream trust gate, readiness + dev seed,
* the download → tar-extract → install-into-managed-dir flow, and the manual-locate
* fallback (accept a valid folder, reject a missing/blocked one).
*/
import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest'
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { WebContents } from 'electron'
const ROOT = mkdtempSync(join(tmpdir(), 'aerofetch-ffmpeg-test-'))
const USERDATA = join(ROOT, 'userData')
const TEMP = join(ROOT, 'temp')
const RES = join(ROOT, 'resources')
const MANAGED = join(USERDATA, 'bin')
// getBinDir() resolves the dev seed under <getAppPath()>/resources/bin (its is.dev branch),
// so mocking is.dev=true + app.getAppPath()=ROOT points it at RES/bin. Deliberately NOT
// mutating the process-global process.resourcesPath, which would leak across test files.
// Shared, per-test-tunable control surface (hoisted so the vi.mock factories can read it).
const h = vi.hoisted(() => ({
streamOk: true,
streamError: 'network boom',
tarFail: false,
extractWrites: true,
validateOk: true,
dialog: { canceled: false, filePaths: [] as string[] }
}))
vi.mock('@electron-toolkit/utils', () => ({ is: { dev: true } }))
vi.mock('electron', () => ({
app: {
getPath: (k: string) => (k === 'userData' ? join(ROOT, 'userData') : join(ROOT, 'temp')),
getAppPath: () => ROOT
},
dialog: { showOpenDialog: async () => h.dialog },
BrowserWindow: class {}
}))
vi.mock('../src/main/logger', () => ({
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }
}))
// ffmpeg version read is display-only; stub it so readiness comes purely from file presence.
vi.mock('../src/main/ffmpeg', () => ({
getFfmpegVersions: async () => ({ ffmpeg: 'mock', ffprobe: 'mock' })
}))
// The streamer is pinned elsewhere: simulate a written archive + one progress tick.
vi.mock('../src/main/lib/verifiedDownload', () => ({
streamVerifiedFile: async (opts: {
filePath: string
onProgress: (p: { received: number; total: number; fraction: number }) => void
}) => {
if (!h.streamOk) return { ok: false, error: h.streamError }
writeFileSync(opts.filePath, 'FAKE-ZIP')
opts.onProgress({ received: 100, total: 100, fraction: 1 })
return { ok: true, filePath: opts.filePath }
}
}))
// tar.exe extract + `<tool> -version` validation both go through execFile.
vi.mock('child_process', () => ({
execFile: (
file: string,
args: string[],
_opts: unknown,
cb: (err: Error | null, stdout?: string) => void
) => {
if (file.endsWith('tar.exe')) {
if (h.tarFail) return cb(new Error('tar failed'))
const dest = args[args.indexOf('-C') + 1]
if (h.extractWrites) {
writeFileSync(join(dest, 'ffmpeg.exe'), 'FF')
writeFileSync(join(dest, 'ffprobe.exe'), 'FP')
}
return cb(null)
}
if (args.includes('-version')) {
return h.validateOk ? cb(null, 'ffmpeg version 8.1.2') : cb(new Error('blocked'))
}
return cb(null)
}
}))
const { isTrustedFfmpegUrl, getFfmpegSetupStatus, downloadFfmpeg, locateFfmpegManually } =
await import('../src/main/ffmpegSetup')
function fakeWc(): { wc: WebContents; sends: unknown[] } {
const sends: unknown[] = []
const wc = {
isDestroyed: () => false,
send: (_ch: string, p: unknown) => sends.push(p)
} as unknown as WebContents
return { wc, sends }
}
beforeEach(() => {
rmSync(MANAGED, { recursive: true, force: true })
rmSync(join(RES, 'bin'), { recursive: true, force: true })
mkdirSync(TEMP, { recursive: true })
Object.assign(h, {
streamOk: true,
streamError: 'network boom',
tarFail: false,
extractWrites: true,
validateOk: true,
dialog: { canceled: false, filePaths: [] }
})
})
afterAll(() => rmSync(ROOT, { recursive: true, force: true }))
describe('isTrustedFfmpegUrl', () => {
it('accepts HTTPS on an allowlisted upstream host', () => {
expect(
isTrustedFfmpegUrl('https://github.com/GyanD/codexffmpeg/releases/download/x/f.zip')
).toBe(true)
expect(isTrustedFfmpegUrl('https://release-assets.githubusercontent.com/abc?x=1')).toBe(true)
})
it('rejects http, an off-list host, and garbage', () => {
expect(isTrustedFfmpegUrl('http://github.com/x.zip')).toBe(false)
expect(isTrustedFfmpegUrl('https://evil.example/x.zip')).toBe(false)
expect(isTrustedFfmpegUrl('not a url')).toBe(false)
})
})
describe('getFfmpegSetupStatus', () => {
it('is ready only when both binaries are present', async () => {
mkdirSync(MANAGED, { recursive: true })
writeFileSync(join(MANAGED, 'ffmpeg.exe'), 'x')
expect((await getFfmpegSetupStatus()).ready).toBe(false) // ffprobe missing
writeFileSync(join(MANAGED, 'ffprobe.exe'), 'x')
expect((await getFfmpegSetupStatus()).ready).toBe(true)
})
it('seeds the managed copies from the dev bundle when present', async () => {
mkdirSync(join(RES, 'bin'), { recursive: true })
writeFileSync(join(RES, 'bin', 'ffmpeg.exe'), 'seed')
writeFileSync(join(RES, 'bin', 'ffprobe.exe'), 'seed')
const status = await getFfmpegSetupStatus()
expect(status.ready).toBe(true)
expect(existsSync(join(MANAGED, 'ffmpeg.exe'))).toBe(true)
})
})
describe('downloadFfmpeg', () => {
it('downloads, extracts, and installs both binaries into the managed dir', async () => {
const { wc, sends } = fakeWc()
const r = await downloadFfmpeg(wc)
expect(r).toEqual({ ok: true })
expect(existsSync(join(MANAGED, 'ffmpeg.exe'))).toBe(true)
expect(existsSync(join(MANAGED, 'ffprobe.exe'))).toBe(true)
// A downloading tick and the extracting phase both reach the renderer.
expect(sends).toContainEqual(expect.objectContaining({ phase: 'downloading' }))
expect(sends).toContainEqual(expect.objectContaining({ phase: 'extracting' }))
})
it('surfaces a download failure and installs nothing', async () => {
h.streamOk = false
const { wc } = fakeWc()
const r = await downloadFfmpeg(wc)
expect(r.ok).toBe(false)
expect(r.error).toMatch(/network boom/)
expect(existsSync(join(MANAGED, 'ffmpeg.exe'))).toBe(false)
})
it('fails when extraction fails, leaving the managed dir empty', async () => {
h.tarFail = true
const { wc } = fakeWc()
const r = await downloadFfmpeg(wc)
expect(r.ok).toBe(false)
expect(existsSync(join(MANAGED, 'ffmpeg.exe'))).toBe(false)
})
it('fails when the archive lacks the expected binaries', async () => {
h.extractWrites = false
const { wc } = fakeWc()
const r = await downloadFfmpeg(wc)
expect(r.ok).toBe(false)
expect(r.error).toMatch(/missing/i)
})
})
describe('locateFfmpegManually', () => {
function folderWith(files: string[]): string {
const dir = mkdtempSync(join(ROOT, 'located-'))
for (const f of files) writeFileSync(join(dir, f), 'x')
return dir
}
it('installs from a folder that holds both runnable binaries', async () => {
h.dialog = { canceled: false, filePaths: [folderWith(['ffmpeg.exe', 'ffprobe.exe'])] }
const r = await locateFfmpegManually()
expect(r).toEqual({ ok: true })
expect(existsSync(join(MANAGED, 'ffprobe.exe'))).toBe(true)
})
it('rejects a folder missing a binary', async () => {
h.dialog = { canceled: false, filePaths: [folderWith(['ffmpeg.exe'])] }
const r = await locateFfmpegManually()
expect(r.ok).toBe(false)
expect(r.error).toMatch(/doesn't contain/i)
})
it('rejects binaries that will not run', async () => {
h.validateOk = false
h.dialog = { canceled: false, filePaths: [folderWith(['ffmpeg.exe', 'ffprobe.exe'])] }
const r = await locateFfmpegManually()
expect(r.ok).toBe(false)
expect(r.error).toMatch(/could not be run/i)
})
it('reports a dismissed folder picker without installing anything', async () => {
h.dialog = { canceled: true, filePaths: [] }
const r = await locateFfmpegManually()
expect(r.ok).toBe(false)
expect(r.error).toMatch(/no folder selected/i)
})
})