feat: add native Windows broadcaster and live score modules
- 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>
This commit is contained in:
381
streamer/windows/install.ts
Normal file
381
streamer/windows/install.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
Reference in New Issue
Block a user