import { createHash } from 'node:crypto'; import { readdir, stat } from 'node:fs/promises'; import path from 'node:path'; /** * Locates the M-Extension-Server release bundle for the host platform. Each * bundle ships a matching JRE alongside the server JAR, so no system JDK is * required. */ export const BUNDLE_RELEASES_URL = 'https://api.github.com/repos/1Selxo/M-Extension-Server/releases?page=1&per_page=10'; /** * The bridge release this integration was verified against. * * Upstream publishes no checksums for the desktop bundles, so we pin a tag and * a hash we computed ourselves rather than tracking "latest". Bumping this * means downloading the new asset, verifying it starts and reports the * capabilities in `AnimeBridgeClient.isReady`, then updating both fields. */ export const PINNED_BUNDLE_TAG = 'v1.0.6.0'; /** SHA-256 of each pinned asset, keyed by release asset name. */ export const PINNED_BUNDLE_SHA256: Readonly> = { 'macOS-arm64-bundle.zip': '5f4fb03abfe88bc46ddf5f4d8221156ee2d66b9cbad7c4bc3ade4baf3a4266e6', }; /** * Check a downloaded asset against its pin. Assets we have not verified * ourselves are rejected rather than trusted, so an unpinned platform fails * loudly instead of silently running an unchecked binary. */ export function verifyPinnedBundle( assetName: string, bytes: Uint8Array, ): { ok: true } | { ok: false; reason: string } { const expected = PINNED_BUNDLE_SHA256[assetName]; if (expected === undefined) { return { ok: false, reason: `No pinned checksum for ${assetName}; refusing to run it.` }; } const actual = sha256(bytes); if (actual !== expected) { return { ok: false, reason: `Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}.`, }; } return { ok: true }; } /** Release asset name for a platform/arch pair, or null when unsupported. */ export function resolveBundleAssetName(platform: string, arch: string): string | null { if (platform === 'linux') return arch === 'x64' ? 'linux-x64-bundle.zip' : null; if (platform === 'win32') return arch === 'x64' ? 'windows-x64-bundle.zip' : null; if (platform === 'darwin') { if (arch === 'arm64') return 'macOS-arm64-bundle.zip'; if (arch === 'x64') return 'macOS-x64-bundle.zip'; } return null; } export interface BundleBinaries { javaPath: string; jarPath: string; } async function walk(dir: string, depth: number, onFile: (file: string) => void): Promise { if (depth < 0) return; let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) await walk(full, depth - 1, onFile); else onFile(full); } } /** * Find the bundled `java` executable and `MExtensionServer-*.jar` inside an * extracted bundle. The archive nests them a few levels deep and the exact * layout differs per platform, so this searches rather than assuming a path. */ export async function findBundleBinaries(rootDir: string): Promise { const javaCandidates: string[] = []; const jarCandidates: string[] = []; await walk(rootDir, 6, (file) => { const base = path.basename(file); if (base === 'java' || base === 'java.exe') javaCandidates.push(file); else if (/^MExtensionServer.*\.jar$/.test(base)) jarCandidates.push(file); }); // Prefer the shallowest match so a nested duplicate never shadows the real one. const byDepth = (a: string, b: string) => a.split(path.sep).length - b.split(path.sep).length; const javaPath = javaCandidates.sort(byDepth)[0]; const jarPath = jarCandidates.sort(byDepth)[0]; if (!javaPath || !jarPath) return null; return { javaPath, jarPath }; } /** Verify a downloaded archive against a pinned SHA-256, as Mangatan does. */ export function sha256(bytes: Uint8Array): string { return createHash('sha256').update(bytes).digest('hex'); } export async function isExecutableFile(file: string): Promise { try { const info = await stat(file); return info.isFile(); } catch { return false; } } export interface BundleAsset { tagName: string; assetName: string; downloadUrl: string; sizeBytes: number; } interface GithubRelease { tag_name?: string; assets?: Array<{ name?: string; browser_download_url?: string; size?: number }>; } /** * Pick the newest release carrying an asset for this platform. Releases are * returned newest-first, and some (the iOS runtime) carry no desktop bundle. */ export function selectBundleAsset(releases: unknown, assetName: string): BundleAsset | null { if (!Array.isArray(releases)) return null; for (const release of releases as GithubRelease[]) { const asset = release.assets?.find((candidate) => candidate.name === assetName); if (asset?.browser_download_url && release.tag_name) { return { tagName: release.tag_name, assetName, downloadUrl: asset.browser_download_url, sizeBytes: asset.size ?? 0, }; } } return null; }