/** * Installs an instance's programs from pinned, checksummed releases, and * copies this program into a fixed place for the scheduled task. * * Nothing is downloaded on start: setup verifies each archive's SHA-256 * against the pin below before extracting it. To upgrade one, change its pin * (URL and checksum from the release page), rebuild, and re-run setup. */ import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; import { copyFile, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; import { basename, dirname, join } from 'node:path'; import type { Layout } from './config'; import { powershell } from './tasks'; export interface Pin { name: string; version: string; url: string; sha256: string; bytes: number; } /** Checked against each project's GitHub release API on 2026-09-29. */ export const PINS = { obs: { name: 'OBS Studio', version: '32.2.2', url: 'https://github.com/obsproject/obs-studio/releases/download/32.2.2/OBS-Studio-32.2.2-Windows-x64.zip', sha256: '4d6e40e3ab155f56b30de517380566a206d74b63cdf5ad49aa596924768f97e1', bytes: 187_851_583 }, tosu: { name: 'tosu', version: '4.26.2', url: 'https://github.com/tosuapp/tosu/releases/download/v4.26.2/tosu-windows-v4.26.2.zip', sha256: 'd34c2bfc495959fcc79ff01f10b7dc9aea77eb508ed23c8f0d0fb8342773faa3', bytes: 0 }, ffmpeg: { name: 'FFmpeg (gyan.dev essentials)', version: '9.0.2', url: 'https://github.com/GyanD/codexffmpeg/releases/download/9.0.2/ffmpeg-9.0.2-essentials_build.zip', sha256: '60f467265b1e312373dbcd92200c2618a74850f98d3d078e94296bb3fa2047ba', bytes: 114_768_076 } } satisfies Record; export async function sha256File(path: string) { const hash = createHash('sha256'); const file = Bun.file(path); for await (const chunk of file.stream()) hash.update(chunk); return hash.digest('hex'); } /** Downloads a pinned archive once into downloads\ and verifies it. */ export async function fetchPinned(pin: Pin, downloads: string, log: (line: string) => void) { await mkdir(downloads, { recursive: true }); const target = join(downloads, basename(new URL(pin.url).pathname)); if (existsSync(target) && (await sha256File(target)) === pin.sha256) return target; const partial = `${target}.partial`; log(`Downloading ${pin.name} ${pin.version}…`); const response = await fetch(pin.url, { redirect: 'follow' }); if (!response.ok || !response.body) throw new Error(`Download of ${pin.name} failed: ${response.status} ${pin.url}`); const total = Number(response.headers.get('content-length')) || pin.bytes || 0; const hash = createHash('sha256'); const writer = Bun.file(partial).writer(); let received = 0; let shown = 0; for await (const chunk of response.body) { hash.update(chunk); writer.write(chunk); received += chunk.byteLength; if (total && received / total - shown >= 0.2) { shown = received / total; log(` ${Math.round(shown * 100)}%`); } } await writer.end(); const digest = hash.digest('hex'); if (digest !== pin.sha256) { await rm(partial, { force: true }); throw new Error( `${pin.name} download did not match its pinned SHA-256 (got ${digest}); refusing to install it` ); } await rename(partial, target); return target; } /** Extracts a zip with Windows' built-in tar (bsdtar), falling back to PowerShell. */ export async function extractZip(archive: string, into: string) { await rm(into, { recursive: true, force: true }); await mkdir(into, { recursive: true }); // Windows 10 and 11 ship bsdtar, which reads zip; another tar earlier in PATH may not. const tarExe = process.platform === 'win32' ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe') : 'tar'; const tar = Bun.spawn([tarExe, '-xf', archive, '-C', into], { stdout: 'ignore', stderr: 'pipe', windowsHide: true }); if ((await tar.exited) === 0) return; if (process.platform !== 'win32') throw new Error(`Could not extract ${archive}: ${await new Response(tar.stderr).text()}`); await powershell( `Expand-Archive -LiteralPath $env:VASH_ARCHIVE -DestinationPath $env:VASH_INTO -Force`, { VASH_ARCHIVE: archive, VASH_INTO: into } ).catch((err) => { throw new Error(`Could not extract ${archive}: ${(err as Error).message}`, { cause: err }); }); } /** Finds a file by name anywhere under a directory. */ export async function findFile(dir: string, name: string): Promise { const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); for (const entry of entries) if (entry.isFile() && entry.name.toLowerCase() === name.toLowerCase()) return join(dir, entry.name); for (const entry of entries) if (entry.isDirectory()) { const found = await findFile(join(dir, entry.name), name); if (found) return found; } return null; } const MARKER = '.vash-version'; async function installedVersion(dir: string) { return (await readFile(join(dir, MARKER), 'utf8').catch(() => '')).trim(); } async function markInstalled(dir: string, pin: Pin) { await writeFile(join(dir, MARKER), `${pin.version} ${pin.sha256}\n`); } export type InstallResult = 'installed' | 'current'; /** * A portable OBS in the instance folder: its own settings, scenes, profile and * websocket, separate from any OBS the user already has. */ export async function installObs( paths: Layout, log: (line: string) => void ): Promise { const pin = PINS.obs; if ( existsSync(paths.obsExe) && (await installedVersion(paths.obs)) === `${pin.version} ${pin.sha256}` ) return 'current'; const archive = await fetchPinned(pin, paths.downloads, log); // Keep OBS's settings (config\) across an upgrade; replace only the program files. const keep = join(paths.dir, 'obs-config.keep'); const hadConfig = existsSync(join(paths.obs, 'config')); if (hadConfig) await rename(join(paths.obs, 'config'), keep); const staging = `${paths.obs}.new`; log(`Extracting ${pin.name}…`); await extractZip(archive, staging); const exe = await findFile(staging, 'obs64.exe'); if (!exe) throw new Error('The OBS archive has no obs64.exe'); // obs64.exe sits in \bin\64bit; portable mode keeps settings in \config. const obsRoot = dirname(dirname(dirname(exe))); await rm(paths.obs, { recursive: true, force: true }); await rename(obsRoot, paths.obs); await rm(staging, { recursive: true, force: true }); if (hadConfig) await rename(keep, join(paths.obs, 'config')); await writeFile(join(paths.obs, 'portable_mode.txt'), ''); await markInstalled(paths.obs, pin); return 'installed'; } export async function installTosu( paths: Layout, log: (line: string) => void ): Promise { const pin = PINS.tosu; if ( existsSync(paths.tosuExe) && (await installedVersion(paths.tosu)) === `${pin.version} ${pin.sha256}` ) return 'current'; const archive = await fetchPinned(pin, paths.downloads, log); const staging = `${paths.tosu}.new`; await extractZip(archive, staging); const exe = await findFile(staging, 'tosu.exe'); if (!exe) throw new Error('The tosu archive has no tosu.exe'); // Keep tosu's own files (tosu.env, static\, settings\) across an upgrade. await mkdir(paths.tosu, { recursive: true }); for (const entry of await readdir(dirname(exe))) await rename(join(dirname(exe), entry), join(paths.tosu, entry)).catch(async () => { await rm(join(paths.tosu, entry), { recursive: true, force: true }); await rename(join(dirname(exe), entry), join(paths.tosu, entry)); }); await rm(staging, { recursive: true, force: true }); await markInstalled(paths.tosu, pin); return 'installed'; } export async function installFfmpeg( paths: Layout, log: (line: string) => void ): Promise { const pin = PINS.ffmpeg; const dir = dirname(paths.ffmpeg); if (existsSync(paths.ffmpeg) && (await installedVersion(dir)) === `${pin.version} ${pin.sha256}`) return 'current'; const archive = await fetchPinned(pin, paths.downloads, log); const staging = `${dir}.new`; await extractZip(archive, staging); const exe = await findFile(staging, 'ffmpeg.exe'); if (!exe) throw new Error('The FFmpeg archive has no ffmpeg.exe'); await mkdir(dir, { recursive: true }); await copyFile(exe, paths.ffmpeg); await rm(staging, { recursive: true, force: true }); await markInstalled(dir, pin); return 'installed'; } /** Where osu! stable is usually installed, newest convention first. */ export function osuCandidates(env: Record = process.env) { const local = env.LOCALAPPDATA; return [ local && join(local, 'osu!', 'osu!.exe'), env['ProgramFiles(x86)'] && join(env['ProgramFiles(x86)']!, 'osu!', 'osu!.exe'), env.ProgramFiles && join(env.ProgramFiles, 'osu!', 'osu!.exe'), 'C:\\osu!\\osu!.exe' ].filter((path): path is string => !!path); } /** osu! stable's path from its osu:// URL handler, if registered. */ async function osuFromRegistry(): Promise { if (process.platform !== 'win32') return null; for (const key of [ 'HKCU\\Software\\Classes\\osu\\shell\\open\\command', 'HKCR\\osu\\shell\\open\\command' ]) { const reg = Bun.spawn(['reg', 'query', key, '/ve'], { stdout: 'pipe', stderr: 'ignore', windowsHide: true }); const output = await new Response(reg.stdout).text(); if ((await reg.exited) !== 0) continue; const match = /"([^"]*osu!\.exe)"/i.exec(output); if (match && existsSync(match[1])) return match[1]; } return null; } export async function findOsu(explicit: string | null): Promise { if (explicit) return existsSync(explicit) ? explicit : null; for (const path of osuCandidates()) if (existsSync(path)) return path; return osuFromRegistry(); } /** * A separate osu! stable folder for osu!tourney, as the official setup guide * says: copy osu!.exe into an empty folder; it installs itself on first run. * The source installation is only read, never changed. */ export async function prepareOsu(paths: Layout, source: string): Promise { if (existsSync(paths.osuExe)) return 'current'; const sourceDir = dirname(source).toLowerCase(); if (sourceDir === paths.osu.toLowerCase()) throw new Error('The tourney installation cannot be the same folder as its source'); await mkdir(paths.osu, { recursive: true }); await copyFile(source, paths.osuExe); return 'installed'; } /** tosu reads this file from its own folder on start. */ export function tosuEnv(port: number) { return [ 'DEBUG_LOG=false', 'ENABLE_AUTOUPDATE=false', 'OPEN_DASHBOARD_ON_STARTUP=false', '', 'SHOW_MP_COMMANDS=false', 'CALCULATE_PP=false', 'READ_MANIA_SCROLL_SPEED=false', '', 'ENABLE_KEY_OVERLAY=false', 'ENABLE_INGAME_OVERLAY=false', '', 'POLL_RATE=150', 'PRECISE_DATA_POLL_RATE=100', '', 'INGAME_OVERLAY_KEYBIND=Control + Shift + Space', 'INGAME_OVERLAY_MAX_FPS=60', '', 'SERVER_IP=127.0.0.1', `SERVER_PORT=${port}`, 'ALLOWED_IPS=127.0.0.1,localhost', '', 'STATIC_FOLDER_PATH=./static', '' ].join('\n'); } export async function writeTosuEnv(paths: Layout, port: number) { await mkdir(paths.tosu, { recursive: true }); await writeFile(join(paths.tosu, 'tosu.env'), tosuEnv(port)); } // ── This program ──────────────────────────────────────────────────────── const IMAGE_SUBSYSTEM_WINDOWS_GUI = 2; const IMAGE_SUBSYSTEM_WINDOWS_CUI = 3; /** * Sets a PE executable's subsystem: 2 (GUI) opens no console window when the * scheduled task starts it; 3 (console) keeps output in an SSH session. This * is the same header field Bun's --windows-hide-console sets. */ export function setSubsystem(exe: Uint8Array, subsystem: number) { const view = new DataView(exe.buffer, exe.byteOffset, exe.byteLength); if (view.getUint16(0, true) !== 0x5a4d) throw new Error('Not an MZ executable'); const pe = view.getUint32(0x3c, true); if (view.getUint32(pe, true) !== 0x00004550) throw new Error('No PE header'); const optional = pe + 24; const magic = view.getUint16(optional, true); if (magic !== 0x20b && magic !== 0x10b) throw new Error('Unknown optional header'); const before = view.getUint16(optional + 68, true); if (before !== IMAGE_SUBSYSTEM_WINDOWS_CUI && before !== IMAGE_SUBSYSTEM_WINDOWS_GUI) throw new Error(`Unexpected subsystem ${before}`); view.setUint16(optional + 68, subsystem, true); // The checksum is not checked for user-mode programs; clear it rather than leave it wrong. view.setUint32(optional + 64, 0, true); return exe; } export function subsystemOf(exe: Uint8Array) { const view = new DataView(exe.buffer, exe.byteOffset, exe.byteLength); const pe = view.getUint32(0x3c, true); return view.getUint16(pe + 24 + 68, true); } /** * Copies the running program into bin\ (the scheduled task points there, so * the downloaded file can be moved or deleted) and writes the windowless copy. */ export async function installSelf(paths: Layout): Promise { const self = process.execPath; if (!/vash-broadcast[^\\/]*\.exe$/i.test(basename(self))) throw new Error( `setup must run from the compiled vash-broadcast.exe (running ${basename(self)}); build it with bun run broadcast:build` ); await mkdir(paths.bin, { recursive: true }); const bytes = new Uint8Array(await readFile(self)); const same = async (path: string, expected: Uint8Array) => { if (!existsSync(path) || (await stat(path)).size !== expected.byteLength) return false; return Buffer.from(await readFile(path)).equals(Buffer.from(expected)); }; const console = setSubsystem(new Uint8Array(bytes), IMAGE_SUBSYSTEM_WINDOWS_CUI); const windowless = setSubsystem(new Uint8Array(bytes), IMAGE_SUBSYSTEM_WINDOWS_GUI); let changed = false; for (const [path, content] of [ [paths.cliExe, console], [paths.workerExe, windowless] ] as const) { if (self.toLowerCase() === path.toLowerCase() || (await same(path, content))) continue; try { await writeFile(`${path}.new`, content); await rm(path, { force: true }); await rename(`${path}.new`, path); changed = true; } catch (err) { throw new Error( `Could not update ${path} (${(err as Error).message}). Stop running instances first: vash-broadcast stop --all`, { cause: err } ); } } return changed ? 'installed' : 'current'; }