103 lines
6.5 KiB
TypeScript
103 lines
6.5 KiB
TypeScript
#!/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<string, string>; targets: Record<string, Archive[]> };
|
|
chrome: { endpoint: string; channel: string; platforms: Record<string, string>; 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<Record<string, Archive[]>> {
|
|
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<string, Archive[]> = {};
|
|
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<ToolsManifest> {
|
|
const targets: Record<string, Archive[]> = {};
|
|
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)`);
|
|
}
|