- streamer/windows: vash-broadcast.exe (setup/login/start/stop/status/doctor) with per-instance OBS, tosu and FFmpeg, a supervised worker, OCR room selection and OBS scene reconciliation; built and tested on Linux only - streamer/relay.ts shared with Vash TV; Cloudflare Access proxy for dev/staging - worker status validation and live score helpers for the site - STREAMING_HANDOFF.md and streamer/ALPHA_TEST.md record what is unverified Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
382 lines
14 KiB
TypeScript
382 lines
14 KiB
TypeScript
/**
|
|
* 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<string, Pin>;
|
|
|
|
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<string | null> {
|
|
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<InstallResult> {
|
|
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 <root>\bin\64bit; portable mode keeps settings in <root>\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<InstallResult> {
|
|
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<InstallResult> {
|
|
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<string, string | undefined> = 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<string | null> {
|
|
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<string | null> {
|
|
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<InstallResult> {
|
|
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<InstallResult> {
|
|
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';
|
|
}
|