#!/usr/bin/env bun // Writes apps/desktop/src-tauri/tools-manifest.json: the exact ffmpeg archives the desktop app may download (URL, // SHA-256, size, files inside), per Rust target triple, plus the Chrome for Testing endpoint and the signers its // downloads must carry. Run before each desktop release (the BtbN autobuilds are pruned after a while): // bun tools/tools-manifest.ts resolve, verify, write // bun tools/tools-manifest.ts --check only check that the manifest parses and every URL still answers // Trust: macOS archives (ffmpeg.martin-riedl.de) are downloaded and hashed here, and that hash must equal the // publisher's .sha256 file. Linux / Windows archives (github.com/BtbN/FFmpeg-Builds, pinned autobuild tag) use the // SHA-256 digest GitHub computes for every release asset, which must equal the publisher's checksums.sha256. So every // pinned hash has two independent sources, and the app checks it again after its own download (docs/desktop.md). import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; export const MANIFEST = join(import.meta.dir, '..', 'apps/desktop/src-tauri/tools-manifest.json'); const FFMPEG_MAJOR = '9.0'; export interface Archive { url: string; sha256: string; size: number; kind: 'zip' | 'tar.xz'; files: { from: string; to: string }[] } export interface ToolsManifest { generated: string; ffmpeg: { version: string; license: string; licenseUrl: string; sources: Record; targets: Record }; chrome: { endpoint: string; channel: string; platforms: Record; macTeamId: string; windowsSigner: string; license: string; licenseUrl: string }; } const sha256 = (b: ArrayBuffer): string => new Bun.CryptoHasher('sha256').update(new Uint8Array(b)).digest('hex'); async function riedl(arch: 'arm64' | 'amd64', tool: 'ffmpeg' | 'ffprobe'): Promise<{ archive: Archive; version: string }> { const r = await fetch(`https://ffmpeg.martin-riedl.de/redirect/latest/macos/${arch}/release/${tool}.zip`, { redirect: 'manual' }); const loc = r.headers.get('location'); if (r.status !== 307 && r.status !== 302 || !loc) throw new Error(`martin-riedl ${arch} ${tool}: no redirect (${r.status})`); const url = new URL(loc, 'https://ffmpeg.martin-riedl.de').toString(); const version = /_(\d+(?:\.\d+)+)\//.exec(url)?.[1] ?? '?'; const published = (await (await fetch(`${url}.sha256`)).text()).trim().split(/\s+/)[0]!; console.log(`download ${url}`); const body = await (await fetch(url)).arrayBuffer(); const got = sha256(body); if (got !== published) throw new Error(`${url}: sha256 ${got} != published ${published}`); return { version, archive: { url, sha256: got, size: body.byteLength, kind: 'zip', files: [{ from: tool, to: tool }] } }; } interface GhAsset { name: string; size: number; digest?: string; browser_download_url: string } async function btbn(): Promise> { const rels = await (await fetch('https://api.github.com/repos/BtbN/FFmpeg-Builds/releases?per_page=5', { headers: { accept: 'application/vnd.github+json' } })).json() as { tag_name: string; assets: GhAsset[] }[]; const rel = rels.find(r => r.tag_name.startsWith('autobuild-')); if (!rel) throw new Error('BtbN: no autobuild release'); const sums = await (await fetch(rel.assets.find(a => a.name === 'checksums.sha256')!.browser_download_url)).text(); const out: Record = {}; const pick = (os: string, triple: string, ext: 'tar.xz' | 'zip', exe: string) => { const re = new RegExp(`^ffmpeg-n${FFMPEG_MAJOR.replace('.', '\\.')}[^-]*-\\d+-g[0-9a-f]+-${os}-gpl-${FFMPEG_MAJOR.replace('.', '\\.')}\\.${ext.replace('.', '\\.')}$`); const a = rel.assets.find(x => re.test(x.name)); if (!a?.digest?.startsWith('sha256:')) throw new Error(`BtbN ${os}: no asset with a digest in ${rel.tag_name}`); const digest = a.digest.slice(7); const line = sums.split('\n').find(l => l.trim().endsWith(a.name)); if (!line || line.split(/\s+/)[0] !== digest) throw new Error(`BtbN ${a.name}: GitHub digest and checksums.sha256 differ`); const top = a.name.replace(/\.(tar\.xz|zip)$/, ''); out[triple] = [{ url: a.browser_download_url, sha256: digest, size: a.size, kind: ext, files: [{ from: `${top}/bin/ffmpeg${exe}`, to: `ffmpeg${exe}` }, { from: `${top}/bin/ffprobe${exe}`, to: `ffprobe${exe}` }] }]; }; pick('linux64', 'x86_64-unknown-linux-gnu', 'tar.xz', ''); pick('linuxarm64', 'aarch64-unknown-linux-gnu', 'tar.xz', ''); pick('win64', 'x86_64-pc-windows-msvc', 'zip', '.exe'); return out; } export async function build(): Promise { const targets: Record = {}; let version = '?'; for (const [arch, triple] of [['arm64', 'aarch64-apple-darwin'], ['amd64', 'x86_64-apple-darwin']] as const) { const ff = await riedl(arch, 'ffmpeg'), fp = await riedl(arch, 'ffprobe'); version = ff.version; targets[triple] = [ff.archive, fp.archive]; } Object.assign(targets, await btbn()); return { generated: new Date().toISOString().slice(0, 10), ffmpeg: { version, license: 'GPL-3.0-or-later (ffmpeg built with GPL components)', licenseUrl: 'https://ffmpeg.org/legal.html', sources: { macos: 'https://ffmpeg.martin-riedl.de/', linux: 'https://github.com/BtbN/FFmpeg-Builds', windows: 'https://github.com/BtbN/FFmpeg-Builds' }, targets, }, chrome: { endpoint: 'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json', channel: 'Stable', platforms: { 'aarch64-apple-darwin': 'mac-arm64', 'x86_64-apple-darwin': 'mac-x64', 'x86_64-unknown-linux-gnu': 'linux64', 'x86_64-pc-windows-msvc': 'win64' }, macTeamId: 'EQHXZ8M8AV', windowsSigner: 'Google LLC', license: 'Chromium BSD-style license and third-party notices; Google Chrome for Testing terms', licenseUrl: 'https://github.com/GoogleChromeLabs/chrome-for-testing#readme', }, }; } if (import.meta.main) { if (process.argv.includes('--check')) { const m = await Bun.file(MANIFEST).json() as ToolsManifest; let bad = 0; for (const [t, list] of Object.entries(m.ffmpeg.targets)) for (const a of list) { const r = await fetch(a.url, { method: 'HEAD' }); console.log(`${r.ok ? 'ok ' : 'BAD'} ${t} ${a.url}`); if (!r.ok) bad++; } process.exit(bad ? 1 : 0); } const m = await build(); writeFileSync(MANIFEST, JSON.stringify(m, null, 2) + '\n'); console.log(`wrote ${MANIFEST} (ffmpeg ${m.ffmpeg.version}, ${Object.keys(m.ffmpeg.targets).length} targets)`); }