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:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user