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:
2026-10-18 22:02:25 +00:00
parent 9d5a046b40
commit 54444a77f0
45 changed files with 8168 additions and 643 deletions

4
streamer/windows/assets.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module '*.ps1' {
const text: string;
export default text;
}

716
streamer/windows/cli.ts Normal file
View File

@@ -0,0 +1,716 @@
#!/usr/bin/env bun
/**
* vash-broadcast: sets up and runs an unattended Vash broadcast on a Windows
* PC, one instance per Vash site (dev, staging, prod). Everything is driven
* from an SSH session; the worker itself runs in the signed-in desktop.
*
* Build with `bun run broadcast:build` (dist/vash-broadcast.exe).
*/
import { existsSync } from 'node:fs';
import { copyFile, readdir, readFile, rm, stat } from 'node:fs/promises';
import { hostname } from 'node:os';
import { join } from 'node:path';
import { parseArgs } from 'node:util';
import { VashApi, VashApiError } from '../vash-api';
import {
accessHeaders,
allocatePorts,
layout,
listInstances,
loadConfig,
readJson,
saveConfig,
secret,
validateServer,
VERSION,
writeJson,
type InstanceConfig
} from './config';
import { callWorker, type Runtime } from './control';
import { WindowsDesktop } from './desktop';
import {
findOsu,
installFfmpeg,
installObs,
installSelf,
installTosu,
PINS,
prepareOsu,
writeTosuEnv
} from './install';
import { lockOwner } from './lock';
import { writeObsSettings } from './obs-files';
import {
consoleUser,
endTask,
gpuNames,
registerTask,
runTask,
taskInfo,
unregisterTask
} from './tasks';
import { readSavedLogin } from './tourney';
import {
runTask as runWatchdog,
runWorker,
stopInstanceProcesses,
type SignInState,
type WorkerState
} from './worker';
const HELP = `vash-broadcast ${VERSION}: an unattended Vash broadcast on this Windows PC.
One-time setup, per instance (dev, staging, prod…):
vash-broadcast setup --instance prod --server https://esports.vash.software --channel <id> --token <token>
Installs OBS, tosu and FFmpeg for this instance, a separate osu!tourney folder,
and a scheduled task that runs the worker in your signed-in desktop.
--osu-exe PATH your osu! stable osu!.exe to copy (found automatically otherwise)
--token-stdin read the agent token from standard input instead of --token
--access-id ID --access-secret SECRET
Cloudflare Access service token, for dev/staging sites behind Access
--tourney-height N osu!tourney streamable height (default 720; the window is ~200 px taller)
--encoder amd|x264 amd (default) uses the Radeon's hardware encoder
--no-autostart do not start the worker when you sign in to Windows
vash-broadcast login --instance prod
One-time sign-in of the spectator osu! account, shown to you through an SSH tunnel.
vash-broadcast login --instance dev --copy-from prod
Reuse another instance's remembered login instead (same Windows user).
Every day, nothing: the worker follows the site. Go live and End stream on the channel's
Stream page start and stop the broadcast. To manage it:
vash-broadcast start|stop|restart --instance prod (stop/restart also take --all)
vash-broadcast status --instance prod [--json]
vash-broadcast logs --instance prod [-n 100] [-f]
vash-broadcast snapshot --instance prod save what OBS and osu!tourney show
vash-broadcast doctor --instance prod check the setup and the desktop
vash-broadcast list
vash-broadcast remove --instance prod --yes unregister (keeps files unless --delete-files)
`;
const { values: args, positionals } = parseArgs({
allowPositionals: true,
options: {
instance: { type: 'string', short: 'i' },
server: { type: 'string' },
channel: { type: 'string' },
token: { type: 'string' },
'token-stdin': { type: 'boolean', default: false },
'access-id': { type: 'string' },
'access-secret': { type: 'string' },
'osu-exe': { type: 'string' },
'tourney-height': { type: 'string' },
encoder: { type: 'string' },
'no-autostart': { type: 'boolean', default: false },
autostart: { type: 'boolean', default: false },
all: { type: 'boolean', default: false },
json: { type: 'boolean', default: false },
follow: { type: 'boolean', short: 'f', default: false },
lines: { type: 'string', short: 'n' },
yes: { type: 'boolean', default: false },
'delete-files': { type: 'boolean', default: false },
'copy-from': { type: 'string' },
help: { type: 'boolean', short: 'h', default: false },
version: { type: 'boolean', short: 'V', default: false }
}
});
const command = positionals[0];
const say = (line = '') => console.log(line);
class UsageError extends Error {}
function instanceName() {
if (!args.instance)
throw new UsageError('Say which instance: --instance dev|staging|prod (or your own name)');
return args.instance;
}
async function running(name: string) {
return callWorker<WorkerState>(layout(name).runtime, '/status');
}
function requireWindows(what: string) {
if (process.platform !== 'win32') throw new Error(`${what} works only on Windows`);
}
// ── setup ───────────────────────────────────────────────────────────────
async function setup() {
const name = instanceName();
const paths = layout(name);
const existing = await loadConfig(name).catch(() => null);
const others = (await listInstances()).filter((config) => config.name !== name);
const serverArg = args.server ?? existing?.server;
if (!serverArg)
throw new UsageError('--server is required, e.g. --server https://esports.vash.software');
const channel = args.channel ?? existing?.channel;
if (!channel)
throw new UsageError('--channel is required (the channel id from its Broadcast page link)');
let token = args.token ?? process.env.VASH_TOKEN ?? existing?.token;
if (args['token-stdin']) token = (await new Response(Bun.stdin.stream()).text()).trim();
if (!token) throw new UsageError('--token is required (Broadcast page → Agent token)');
const accessId = args['access-id'] ?? process.env.VASH_ACCESS_CLIENT_ID;
const accessSecret = args['access-secret'] ?? process.env.VASH_ACCESS_CLIENT_SECRET;
if (!!accessId !== !!accessSecret)
throw new UsageError('--access-id and --access-secret go together');
const height = Number(args['tourney-height'] ?? existing?.tourneyHeight ?? 720);
const encoder = (args.encoder ?? existing?.encoder ?? 'amd') as InstanceConfig['encoder'];
const config: InstanceConfig = {
version: 1,
name,
server: validateServer(serverArg),
channel,
token,
access: accessId
? { clientId: accessId, clientSecret: accessSecret! }
: (existing?.access ?? null),
ports: existing?.ports ?? allocatePorts(others.map((other) => other.ports)),
obsPassword: existing?.obsPassword ?? secret(),
ingestKey: existing?.ingestKey ?? secret(),
osuSource: existing?.osuSource ?? null,
tourneyHeight: height,
encoder,
autostart: args['no-autostart'] ? false : args.autostart ? true : (existing?.autostart ?? true)
};
for (const other of others)
if (other.server === config.server && other.channel === config.channel)
say(`Note: instance "${other.name}" follows the same channel.`);
if (await running(name))
throw new Error(
`The "${name}" worker is running. Stop it first: vash-broadcast stop --instance ${name}`
);
say(`Checking ${config.server} …`);
const api = new VashApi(config.server, config.channel, config.token, accessHeaders(config));
const stream = await api.stream().catch((err) => {
if (err instanceof VashApiError && err.status === 401)
throw new Error(
'Vash refused the channel id or agent token. Copy them again from the Broadcast page.'
);
throw err;
});
say(
` Channel "${stream.name ?? channel}": ${stream.width}×${stream.height} ${stream.fps} fps, ${stream.bitrateKbps} kbps, ` +
`${stream.destinations.length} destination(s), recording ${stream.record ? 'on' : 'off'}`
);
const progress = (line: string) => say(` ${line}`);
if (process.platform === 'win32') {
say('Installing programs (pinned versions, checksums verified)…');
say(` vash-broadcast: ${await installSelf(paths)}`);
await WindowsDesktop.writeScript(paths.helperScript);
say(` FFmpeg ${PINS.ffmpeg.version}: ${await installFfmpeg(paths, progress)}`);
say(` tosu ${PINS.tosu.version}: ${await installTosu(paths, progress)}`);
await writeTosuEnv(paths, config.ports.tosu);
say(
` OBS ${PINS.obs.version} (portable, this instance only): ${await installObs(paths, progress)}`
);
await writeObsSettings(paths.obsConfig, {
websocketPort: config.ports.obs,
websocketPassword: config.obsPassword,
width: stream.width,
height: stream.height,
fps: stream.fps,
bitrateKbps: stream.bitrateKbps,
encoder: config.encoder,
ingestUrl: `rtmp://127.0.0.1:${config.ports.ingest}/live`,
ingestKey: config.ingestKey
});
const source = await findOsu(args['osu-exe'] ?? config.osuSource);
if (!source)
throw new Error(
'Could not find osu! stable. Pass the path of your osu!.exe with --osu-exe; it is only copied, never changed.'
);
config.osuSource = source;
say(
` osu!tourney folder (osu!.exe copied from ${source}): ${await prepareOsu(paths, source)}`
);
} else say('Not on Windows: saving the configuration only.');
await saveConfig(config);
if (process.platform === 'win32') {
await registerTask({
instance: name,
exe: paths.workerExe,
cwd: paths.root,
autostart: config.autostart
});
say(
` Scheduled task: registered${config.autostart ? ', starts when you sign in to Windows' : ''}`
);
}
say();
say(`Instance "${name}" is set up in ${paths.dir}`);
say(
` Ports: control ${config.ports.control}, OBS ${config.ports.obs}, tosu ${config.ports.tosu}, relay ${config.ports.ingest}`
);
const login = await readSavedLogin(paths.osu);
say();
if (!login.saved)
say(`Next, sign in the spectator account once: vash-broadcast login --instance ${name}`);
else
say(
`Spectator login saved (${login.username}). Start it: vash-broadcast start --instance ${name}`
);
}
// ── start / stop ────────────────────────────────────────────────────────
async function requireDesktop() {
const user = await consoleUser();
if (!user)
throw new Error(
'Nobody is signed in to Windows at the PC. The worker runs in the signed-in desktop; sign in once (or turn on automatic sign-in) and try again.'
);
return user;
}
async function start(name: string) {
requireWindows('start');
const paths = layout(name);
await loadConfig(name);
const status = await running(name);
if (status) {
say(
`"${name}" is already running: ${status.state}${status.detail ? ` (${status.detail})` : ''}`
);
return;
}
await requireDesktop();
const task = await taskInfo(name);
if (!task.exists) throw new Error(`No scheduled task for "${name}". Run setup again.`);
await writeJson(paths.request, { mode: 'run', at: Date.now() });
await runTask(name);
say(`Starting "${name}" in the desktop session…`);
for (let waited = 0; waited < 45; waited++) {
await Bun.sleep(1000);
const status = await running(name);
if (status) {
say(`Running: ${status.state}${status.detail ? ` (${status.detail})` : ''}`);
say(
`Follow it with: vash-broadcast status --instance ${name} or vash-broadcast logs --instance ${name} -f`
);
return;
}
}
const info = await taskInfo(name);
throw new Error(
`The worker did not come up in 45 s (task ${info.state}, last result ${info.lastTaskResult}). See: vash-broadcast logs --instance ${name}`
);
}
async function stop(name: string) {
const paths = layout(name);
const runtime = await readJson<Runtime>(paths.runtime);
const status = await running(name);
if (status) {
say(`Stopping "${name}"${status.onAir ? ' (going off air and finishing the recording)' : ''}…`);
await callWorker(paths.runtime, '/stop', { method: 'POST' });
for (let waited = 0; waited < 120; waited++) {
await Bun.sleep(1000);
if (!(await running(name))) {
say('Stopped.');
return;
}
}
say('It did not stop in 2 minutes; stopping it forcibly.');
} else if (!runtime) {
say(`"${name}" is not running.`);
}
// Forcibly: the task (and its watchdog), the worker, then everything in the instance folder.
if (process.platform === 'win32') {
await endTask(name);
const desktop = new WindowsDesktop(paths.helperScript);
try {
const pids = [runtime?.watchdogPid, runtime?.pid].filter(
(pid): pid is number => !!pid && pid !== process.pid
);
await desktop.kill(pids).catch(() => 0);
const killed = await stopInstanceProcesses(desktop, paths, () => {});
if (killed) say(`Stopped ${killed} process(es) of "${name}".`);
} finally {
desktop.stop();
}
}
await rm(paths.runtime, { force: true });
}
// ── status ──────────────────────────────────────────────────────────────
function age(iso: string | null | undefined) {
if (!iso) return 'never';
const seconds = Math.round((Date.now() - Date.parse(iso)) / 1000);
return seconds < 90
? `${seconds}s ago`
: seconds < 5400
? `${Math.round(seconds / 60)} min ago`
: `${Math.round(seconds / 3600)} h ago`;
}
function printState(state: WorkerState) {
say(`State: ${state.state}${state.detail ? ` — ${state.detail}` : ''}`);
if (state.match?.title)
say(
`Match: ${state.match.title}${state.match.lobbyId ? ` (mp/${state.match.lobbyId})` : ' (no osu! lobby)'}`
);
else say('Match: none; the channel is idle');
say(
`Tourney: ${state.tourney.state}${state.tourney.detail ? ` — ${state.tourney.detail}` : ''}${state.tourney.verified ? ' ✓ confirmed' : ''}`
);
say(
`Broadcast: ${state.live ? 'Go live is on' : 'off air'}${state.onAir ? `; OBS ${state.obsStreaming ? 'streaming' : 'NOT streaming'}, relay ${state.relayReceiving ? 'receiving' : 'waiting'}` : ''}`
);
if (state.encoder) say(`Encoder: ${state.encoder}`);
for (const destination of state.destinations)
say(
` → ${destination.label}: ${destination.state}${destination.error ? ` (${destination.error})` : ''}`
);
const components = state.components as Record<string, Record<string, unknown>>;
for (const [label, component] of Object.entries(components))
say(
`${`${label}:`.padEnd(11)}${Object.entries(component)
.filter(([, value]) => value !== null && value !== undefined)
.map(([key, value]) => `${key} ${value}`)
.join(', ')}`
);
say(
`Vash: ${state.vash.connected ? `connected (channel "${state.vash.channel}")` : 'not connected'}${state.vash.lastError ? `; ${state.vash.lastError}` : ''}`
);
if (state.desktop)
say(
`Desktop: ${state.desktop.inputDesktop === 'Default' ? 'unlocked' : 'LOCKED'}, ${state.desktop.screenWidth}×${state.desktop.screenHeight}`
);
for (const warning of state.warnings) say(`Warning: ${warning}`);
say(`Worker: pid ${state.pid}, up since ${state.startedAt} (v${state.version})`);
}
async function status(name: string) {
const paths = layout(name);
const live = await running(name);
if (args.json) {
say(JSON.stringify(live ?? { running: false, last: await readJson(paths.state) }, null, 2));
return;
}
say(`Instance "${name}"`);
if (live) {
printState(live);
return;
}
say('Worker: not running');
const last = await readJson<WorkerState>(paths.state);
if (last)
say(
`Last seen: ${last.state}${last.detail ? ` — ${last.detail}` : ''} (${age(last.updatedAt)})`
);
const login = await readSavedLogin(paths.osu);
say(
`Login: ${login.saved ? `saved (${login.username})` : `not saved; run vash-broadcast login --instance ${name}`}`
);
if (process.platform === 'win32') {
const task = await taskInfo(name).catch(() => null);
if (task)
say(
`Task: ${task.exists ? `${task.state}, last run ${age(task.lastRunTime)} (result ${task.lastTaskResult})${task.autostart ? ', starts at sign-in' : ''}` : 'not registered; run setup'}`
);
}
const owner = await lockOwner(paths.lock);
if (owner && owner.instance !== name)
say(`Tourney: instance "${owner.instance}" is running osu!tourney on this PC`);
}
// ── logs, snapshot, doctor, list, remove ──────────────────────────────────
async function logs(name: string) {
const path = layout(name).log;
if (!existsSync(path)) {
say('No log yet.');
return;
}
const count = Number(args.lines ?? 60);
const text = await readFile(path, 'utf8');
say(text.trimEnd().split('\n').slice(-count).join('\n'));
if (!args.follow) return;
let offset = (await stat(path)).size;
for (;;) {
await Bun.sleep(1000);
const size = (await stat(path).catch(() => null))?.size ?? 0;
if (size < offset) offset = 0;
if (size > offset) {
const file = Bun.file(path).slice(offset, size);
process.stdout.write(await file.text());
offset = size;
}
}
}
async function snapshot(name: string) {
const result = await callWorker<{ files: string[]; errors: string[] }>(
layout(name).runtime,
'/snapshot',
{
method: 'POST',
timeoutMs: 30_000
}
);
if (!result) throw new Error(`"${name}" is not running.`);
for (const file of result.files) say(file);
for (const error of result.errors) say(`Could not capture: ${error}`);
if (result.files.length)
say(
`\nCopy them to your Mac with, for example: scp "${process.env.USERNAME ?? 'you'}@${hostname()}:${result.files[0].replace(/\\/g, '/')}" .`
);
}
async function doctor(name: string) {
const paths = layout(name);
const results: [boolean | null, string, string][] = [];
const check = async (label: string, run: () => Promise<string | [boolean | null, string]>) => {
try {
const outcome = await run();
results.push(
typeof outcome === 'string' ? [true, label, outcome] : [outcome[0], label, outcome[1]]
);
} catch (err) {
results.push([false, label, (err as Error).message]);
}
};
const config = await loadConfig(name);
await check('Vash', async () => {
const stream = await new VashApi(
config.server,
config.channel,
config.token,
accessHeaders(config)
).stream();
return `${config.server}: channel "${stream.name}" answers; Go live is ${stream.live ? 'on' : 'off'}`;
});
for (const [label, path] of [
['OBS', paths.obsExe],
['tosu', paths.tosuExe],
['FFmpeg', paths.ffmpeg],
['osu!tourney folder', paths.osuExe],
['Worker program', paths.workerExe]
] as const)
await check(label, async () =>
existsSync(path) ? path : [false, `missing ${path}; run setup`]
);
await check('Spectator login', async () => {
const login = await readSavedLogin(paths.osu);
return login.saved
? `saved (${login.username})`
: [false, `not saved; run vash-broadcast login --instance ${name}`];
});
if (process.platform === 'win32') {
await check('Scheduled task', async () => {
const task = await taskInfo(name);
return task.exists
? `${task.state}, last result ${task.lastTaskResult}`
: [false, 'not registered; run setup'];
});
await check('Signed-in desktop', async () => `${await requireDesktop()} is signed in`);
await check('GPU', async () => {
const gpus = await gpuNames();
const amd = gpus.some((gpu) => /radeon|amd/i.test(gpu));
return [amd || config.encoder !== 'amd' ? true : null, gpus.join(', ') || 'unknown'];
});
}
await check('Tourney lock', async () => {
const owner = await lockOwner(paths.lock);
return !owner
? 'free'
: owner.instance === name
? 'held by this instance'
: [null, `held by "${owner.instance}"`];
});
const live = await running(name);
if (live) {
results.push([
live.state === 'error' ? false : true,
'Worker',
`${live.state}${live.detail ? ` — ${live.detail}` : ''}`
]);
if (live.desktop)
results.push([
live.desktop.inputDesktop === 'Default',
'Desktop',
live.desktop.inputDesktop === 'Default'
? 'unlocked'
: 'locked: rooms cannot be selected while Windows is locked'
]);
} else if (process.platform === 'win32') {
// Checks that only mean something inside the desktop session run there, through the task.
await rm(paths.doctor, { force: true });
await writeJson(paths.request, { mode: 'doctor', at: Date.now() });
await runTask(name);
let report: { checks: { name: string; ok: boolean; detail: string }[] } | null = null;
for (let waited = 0; waited < 60 && !report; waited++) {
await Bun.sleep(1000);
report = await readJson(paths.doctor);
}
if (!report) results.push([false, 'Desktop checks', 'the task did not report back in 60 s']);
else for (const each of report.checks) results.push([each.ok, each.name, each.detail]);
}
for (const [ok, label, detail] of results)
say(`${ok === true ? ' ok ' : ok === false ? 'FAIL' : ' ?? '} ${label}: ${detail}`);
if (results.some(([ok]) => ok === false)) process.exitCode = 1;
}
async function list() {
const configs = await listInstances();
if (!configs.length) {
say('No instances. Set one up with: vash-broadcast setup --instance prod …');
return;
}
for (const config of configs) {
const live = await running(config.name);
say(
`${config.name.padEnd(10)} ${config.server} ${live ? `${live.state}${live.onAir ? ', on air' : ''}` : 'stopped'}`
);
}
}
async function remove(name: string) {
if (!args.yes) throw new UsageError(`This unregisters "${name}". Add --yes to confirm.`);
await stop(name);
if (process.platform === 'win32') await unregisterTask(name);
if (args['delete-files']) {
await rm(layout(name).dir, { recursive: true, force: true });
say(`Removed "${name}" and its files.`);
} else say(`Unregistered "${name}"; its files are still in ${layout(name).dir}.`);
}
/** Copies osu!'s remembered login from another instance's tourney folder. */
async function copyLogin(name: string, from: string) {
const source = layout(from);
const target = layout(name);
await loadConfig(from);
const login = await readSavedLogin(source.osu);
if (!login.saved) throw new Error(`Instance "${from}" has no saved login to copy.`);
if (await running(name))
throw new Error(`Stop "${name}" first: vash-broadcast stop --instance ${name}`);
for (const file of (await readdir(source.osu)).filter((each) => /^osu!\..+\.cfg$/i.test(each)))
await copyFile(join(source.osu, file), join(target.osu, file));
const copied = await readSavedLogin(target.osu);
if (!copied.saved)
throw new Error('The login did not copy; use vash-broadcast login without --copy-from.');
say(`Copied the remembered login (${copied.username}) from "${from}" to "${name}".`);
say('If osu!tourney still asks to sign in, run vash-broadcast login without --copy-from.');
}
async function login(name: string) {
if (args['copy-from']) return copyLogin(name, args['copy-from']);
requireWindows('login');
const paths = layout(name);
await loadConfig(name);
if (await running(name))
throw new Error(
`Stop the worker first (signing in closes osu!tourney): vash-broadcast stop --instance ${name}`
);
await requireDesktop();
await rm(paths.login, { force: true });
await writeJson(paths.request, { mode: 'login', at: Date.now() });
await runTask(name);
let state: SignInState | null = null;
for (let waited = 0; waited < 60 && state?.state !== 'waiting'; waited++) {
await Bun.sleep(1000);
state = await readJson<SignInState>(paths.login);
if (state?.state === 'failed') throw new Error(state.message);
}
if (!state)
throw new Error(`The sign-in did not start. See: vash-broadcast logs --instance ${name}`);
const user = process.env.USERNAME ?? 'you';
say(`osu! is open on ${hostname()}'s screen for the one-time spectator sign-in.
On your Mac, open a tunnel (leave it running):
ssh -N -L ${state.port}:127.0.0.1:${state.port} ${user}@${hostname().toLowerCase()}
Then open this link in your browser:
http://127.0.0.1:${state.port}/#${state.token}
Sign in with the spectator account (it needs osu!supporter for osu!tourney), tick
Remember username and Remember password, then press "I have signed in: finish".
`);
let last = '';
for (;;) {
await Bun.sleep(2000);
const next = await readJson<SignInState>(paths.login);
if (!next) continue;
if (next.message !== last) say(next.message);
last = next.message;
if (next.state === 'done') {
say(`\nNext: vash-broadcast start --instance ${name}`);
return;
}
if (next.state === 'failed') {
process.exitCode = 1;
return;
}
}
}
// ── main ────────────────────────────────────────────────────────────────
async function main() {
if (args.version) return say(VERSION);
if (args.help || !command) return say(HELP);
switch (command) {
case 'setup':
return setup();
case 'login':
return login(instanceName());
case 'start':
return start(instanceName());
case 'stop':
if (args.all) {
for (const config of await listInstances()) await stop(config.name);
return;
}
return stop(instanceName());
case 'restart':
if (args.all) {
const configs = await listInstances();
for (const config of configs)
if (await running(config.name)) {
await stop(config.name);
await start(config.name);
}
return;
}
await stop(instanceName());
return start(instanceName());
case 'status':
return status(instanceName());
case 'logs':
return logs(instanceName());
case 'snapshot':
return snapshot(instanceName());
case 'doctor':
return doctor(instanceName());
case 'list':
return list();
case 'remove':
return remove(instanceName());
// Started by the scheduled task, inside the desktop session:
case 'task':
process.exitCode = await runWatchdog(instanceName());
return;
case 'worker':
process.exit(await runWorker(instanceName()));
return;
default:
throw new UsageError(`Unknown command "${command}"`);
}
}
try {
await main();
} catch (err) {
console.error(
err instanceof UsageError
? `${err.message}\n\nRun vash-broadcast --help for usage.`
: `Error: ${(err as Error).message}`
);
process.exit(1);
}

239
streamer/windows/config.ts Normal file
View File

@@ -0,0 +1,239 @@
/**
* Where an instance lives and what it is configured with.
*
* %LOCALAPPDATA%\Vash\Broadcast\ (VASH_BROADCAST_HOME overrides)
* bin\vash-broadcast.exe the CLI, copied here by setup
* bin\vash-broadcast-worker.exe the same program without a console window
* bin\vash-desktop.ps1 desktop helper (windows, capture, OCR, input)
* downloads\ verified archives, reused by every instance
* tourney.lock which instance runs osu!tourney + tosu now
* instances\<name>\
* config.json server, channel, token, ports, options
* obs\ osu\ tosu\ ffmpeg\ this instance's own copies of each program
* hls\ logs\ snapshots\ recording scratch, logs, diagnostics
* state.json runtime.json what the worker is doing; how to reach it
*
* Every program an instance runs lives under its own folder, so stopping one
* instance can find and stop exactly its processes and nothing else.
*/
import { randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs';
import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
export const VERSION = '0.1.0';
export function rootDir() {
if (process.env.VASH_BROADCAST_HOME) return process.env.VASH_BROADCAST_HOME;
if (process.platform === 'win32')
return join(
process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'),
'Vash',
'Broadcast'
);
return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'vash-broadcast');
}
export const INSTANCE_NAME = /^[a-z][a-z0-9-]{0,19}$/;
export function instanceDir(name: string) {
if (!INSTANCE_NAME.test(name))
throw new Error(
`Instance names are 1–20 lowercase letters, digits or dashes, starting with a letter (got "${name}")`
);
return join(rootDir(), 'instances', name);
}
/** Paths inside an instance folder. */
export function layout(name: string) {
const dir = instanceDir(name);
const root = rootDir();
return {
root,
dir,
config: join(dir, 'config.json'),
state: join(dir, 'state.json'),
runtime: join(dir, 'runtime.json'),
request: join(dir, 'request.json'),
login: join(dir, 'login.json'),
doctor: join(dir, 'doctor.json'),
logs: join(dir, 'logs'),
log: join(dir, 'logs', 'worker.log'),
snapshots: join(dir, 'snapshots'),
hls: join(dir, 'hls'),
obs: join(dir, 'obs'),
obsExe: join(dir, 'obs', 'bin', '64bit', 'obs64.exe'),
obsConfig: join(dir, 'obs', 'config', 'obs-studio'),
osu: join(dir, 'osu'),
osuExe: join(dir, 'osu', 'osu!.exe'),
tosu: join(dir, 'tosu'),
tosuExe: join(dir, 'tosu', 'tosu.exe'),
ffmpeg: join(dir, 'ffmpeg', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'),
bin: join(root, 'bin'),
cliExe: join(root, 'bin', 'vash-broadcast.exe'),
workerExe: join(root, 'bin', 'vash-broadcast-worker.exe'),
helperScript: join(root, 'bin', 'vash-desktop.ps1'),
downloads: join(root, 'downloads'),
lock: join(root, 'tourney.lock')
};
}
export type Layout = ReturnType<typeof layout>;
export interface Ports {
/** The worker's local control API (status, stop, snapshots). */
control: number;
/** obs-websocket of this instance's OBS. */
obs: number;
/** tosu's HTTP/websocket server. */
tosu: number;
/** RTMP ingest OBS streams into; the relay records and restreams it. */
ingest: number;
/** The one-time sign-in screen viewer. */
viewer: number;
/** Local overlay proxy for sites behind Cloudflare Access (OBS cannot send its headers). */
proxy: number;
}
export interface InstanceConfig {
version: 1;
name: string;
/** The Vash site, e.g. https://esports.vash.software */
server: string;
channel: string;
/** The channel's agent token (Broadcast page → Agent token). */
token: string;
/** Cloudflare Access service token for sites behind Access (dev, staging). */
access: { clientId: string; clientSecret: string } | null;
ports: Ports;
obsPassword: string;
ingestKey: string;
/** Where osu!.exe was copied from for this instance's separate tourney installation. */
osuSource: string | null;
/** osu!tourney's streamable height (tournament.cfg Height); the window is ~200 px taller. */
tourneyHeight: number;
/** 'amd' asks OBS for AMD AMF hardware encoding; 'x264' chooses software encoding on purpose. */
encoder: 'amd' | 'x264';
/** Start the worker when this Windows user signs in. */
autostart: boolean;
}
const PORT_BASE = 47100;
const PORT_STEP = 10;
export function portsForSlot(slot: number): Ports {
const base = PORT_BASE + slot * PORT_STEP;
return {
control: base,
obs: base + 1,
tosu: base + 2,
ingest: base + 3,
viewer: base + 4,
proxy: base + 5
};
}
/** The first port block no other instance uses. */
export function allocatePorts(taken: Ports[]): Ports {
const used = new Set(taken.map((ports) => ports.control));
for (let slot = 0; slot < 50; slot++) {
const ports = portsForSlot(slot);
if (!used.has(ports.control)) return ports;
}
throw new Error('No free port block for another instance');
}
export const secret = (bytes = 18) => randomBytes(bytes).toString('base64url');
export function validateServer(server: string) {
let url: URL;
try {
url = new URL(server);
} catch {
throw new Error(`--server must be a URL like https://esports.vash.software (got "${server}")`);
}
if (url.protocol !== 'https:' && !['127.0.0.1', 'localhost'].includes(url.hostname))
throw new Error('--server must use https:// (plain http only for localhost)');
return url.origin;
}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
export function validateConfig(value: unknown): InstanceConfig {
const config = value as InstanceConfig;
const problems: string[] = [];
if (!config || typeof config !== 'object') throw new Error('config.json is not an object');
if (config.version !== 1) problems.push('unknown config version');
if (!INSTANCE_NAME.test(config.name ?? '')) problems.push('bad instance name');
try {
validateServer(config.server);
} catch (err) {
problems.push((err as Error).message);
}
if (!UUID.test(config.channel ?? '')) problems.push('channel must be the channel id (a UUID)');
if (typeof config.token !== 'string' || !config.token) problems.push('missing agent token');
for (const key of ['control', 'obs', 'tosu', 'ingest', 'viewer', 'proxy'] as const)
if (!Number.isInteger(config.ports?.[key])) problems.push(`missing ${key} port`);
if (!config.obsPassword || !config.ingestKey) problems.push('missing generated secrets');
if (!(config.tourneyHeight >= 568 && config.tourneyHeight <= 2160))
problems.push('tourneyHeight must be 568–2160');
if (config.encoder !== 'amd' && config.encoder !== 'x264')
problems.push('encoder must be amd or x264');
if (problems.length) throw new Error(`Invalid instance config: ${problems.join('; ')}`);
return config;
}
export async function loadConfig(name: string): Promise<InstanceConfig> {
const path = layout(name).config;
if (!existsSync(path))
throw new Error(
`Instance "${name}" is not set up. Run: vash-broadcast setup --instance ${name} …`
);
return validateConfig(JSON.parse(await readFile(path, 'utf8')));
}
export async function listInstances(): Promise<InstanceConfig[]> {
const dir = join(rootDir(), 'instances');
const names = await readdir(dir).catch(() => [] as string[]);
const configs: InstanceConfig[] = [];
for (const name of names) {
if (!INSTANCE_NAME.test(name)) continue;
try {
configs.push(await loadConfig(name));
} catch {
/* half-set-up instance; ignore */
}
}
return configs;
}
/** Writes JSON through a temporary file so readers never see half a file. */
export async function writeJson(path: string, value: unknown) {
await mkdir(join(path, '..'), { recursive: true });
const temp = `${path}.${process.pid}.tmp`;
await writeFile(temp, JSON.stringify(value, null, '\t'));
await rename(temp, path);
}
export async function readJson<T>(path: string): Promise<T | null> {
try {
return JSON.parse(await readFile(path, 'utf8')) as T;
} catch {
return null;
}
}
export async function saveConfig(config: InstanceConfig) {
await writeJson(layout(config.name).config, validateConfig(config));
}
/** Headers for a site behind Cloudflare Access. */
export function accessHeaders(config: InstanceConfig): Record<string, string> {
return config.access
? {
'CF-Access-Client-Id': config.access.clientId,
'CF-Access-Client-Secret': config.access.clientSecret
}
: {};
}

View File

@@ -0,0 +1,44 @@
/**
* The running worker answers on 127.0.0.1:<control port> so the CLI (in an
* SSH session) can ask for its status, stop it, or take snapshots. The token
* changes every run and is kept in runtime.json in the user's own profile.
*/
import { readJson } from './config';
export interface Runtime {
pid: number;
watchdogPid: number | null;
port: number;
token: string;
startedAt: string;
version: string;
}
export async function callWorker<T>(
runtimePath: string,
path: string,
init: { method?: string; timeoutMs?: number } = {}
): Promise<T | null> {
const runtime = await readJson<Runtime>(runtimePath);
if (!runtime) return null;
try {
const response = await fetch(`http://127.0.0.1:${runtime.port}${path}`, {
method: init.method ?? 'GET',
headers: { 'x-vash-token': runtime.token },
signal: AbortSignal.timeout(init.timeoutMs ?? 4000)
});
if (!response.ok) return null;
return (await response.json()) as T;
} catch {
return null;
}
}
export function authorized(request: Request, token: string) {
const given = request.headers.get('x-vash-token') ?? '';
if (given.length !== token.length) return false;
let diff = 0;
for (let index = 0; index < token.length; index++)
diff |= given.charCodeAt(index) ^ token.charCodeAt(index);
return diff === 0;
}

View File

@@ -0,0 +1,503 @@
# Vash broadcast desktop helper.
#
# The worker starts this in the signed-in desktop session and sends one JSON
# request per line on stdin; each gets one JSON line back on stdout:
# {"id":1,"cmd":"windows","under":"C:\\...\\osu"} -> {"id":1,"ok":true,"result":[...]}
#
# It needs Windows PowerShell 5.1 (powershell.exe, part of Windows): the
# Windows.Media.Ocr API used for reading osu!tourney's room list is not
# reachable from PowerShell 7. Coordinates are physical screen pixels.
$ErrorActionPreference = 'Stop'
# Explicit UTF-8 streams: [Console]::InputEncoding cannot be set when there is no console window.
$utf8 = New-Object System.Text.UTF8Encoding $false
$stdin = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), $utf8)
$stdout = New-Object System.IO.StreamWriter([Console]::OpenStandardOutput(), $utf8)
$stdout.AutoFlush = $true
Add-Type -AssemblyName System.Drawing
Add-Type -ReferencedAssemblies System.Drawing -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
public class VashWindow {
public long hwnd { get; set; }
public int pid { get; set; }
public string title { get; set; }
public string className { get; set; }
public string exe { get; set; }
public bool visible { get; set; }
public bool minimized { get; set; }
public int x { get; set; }
public int y { get; set; }
public int width { get; set; }
public int height { get; set; }
public int clientX { get; set; }
public int clientY { get; set; }
public int clientWidth { get; set; }
public int clientHeight { get; set; }
}
public class VashProcess {
public int pid { get; set; }
public string path { get; set; }
}
public static class VashDesktop {
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X, Y; }
[StructLayout(LayoutKind.Sequential)] struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }
[StructLayout(LayoutKind.Sequential)] struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }
[StructLayout(LayoutKind.Sequential)] struct HARDWAREINPUT { public uint uMsg; public ushort wParamL; public ushort wParamH; }
[StructLayout(LayoutKind.Explicit)] struct InputUnion {
[FieldOffset(0)] public MOUSEINPUT mi;
[FieldOffset(0)] public KEYBDINPUT ki;
[FieldOffset(0)] public HARDWAREINPUT hi;
}
[StructLayout(LayoutKind.Sequential)] struct INPUT { public uint type; public InputUnion U; }
[StructLayout(LayoutKind.Sequential)] struct LASTINPUTINFO { public uint cbSize; public uint dwTime; }
delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc cb, IntPtr lParam);
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetWindowText(IntPtr hWnd, StringBuilder sb, int max);
[DllImport("user32.dll")] static extern int GetWindowTextLength(IntPtr hWnd);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetClassName(IntPtr hWnd, StringBuilder sb, int max);
[DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")] static extern bool IsWindow(IntPtr hWnd);
[DllImport("user32.dll")] static extern bool IsIconic(IntPtr hWnd);
[DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr hWnd, out RECT r);
[DllImport("user32.dll")] static extern bool GetClientRect(IntPtr hWnd, out RECT r);
[DllImport("user32.dll")] static extern bool ClientToScreen(IntPtr hWnd, ref POINT p);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint msg, IntPtr w, string l, uint flags, uint timeout, out IntPtr result);
[DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] static extern bool ShowWindow(IntPtr hWnd, int cmd);
[DllImport("user32.dll")] static extern bool BringWindowToTop(IntPtr hWnd);
[DllImport("user32.dll")] static extern bool AttachThreadInput(uint a, uint b, bool attach);
[DllImport("kernel32.dll")] static extern uint GetCurrentThreadId();
[DllImport("user32.dll")] static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] static extern uint SendInput(uint n, INPUT[] inputs, int size);
[DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int cx, int cy, uint flags);
[DllImport("user32.dll")] static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr w, IntPtr l);
[DllImport("user32.dll")] static extern IntPtr OpenInputDesktop(uint flags, bool inherit, uint access);
[DllImport("user32.dll")] static extern bool CloseDesktop(IntPtr h);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern bool GetUserObjectInformation(IntPtr h, int index, StringBuilder sb, int len, out int needed);
[DllImport("user32.dll")] static extern bool SetProcessDpiAwarenessContext(IntPtr value);
[DllImport("user32.dll")] static extern int GetSystemMetrics(int index);
[DllImport("user32.dll")] static extern bool GetLastInputInfo(ref LASTINPUTINFO info);
[DllImport("kernel32.dll")] static extern IntPtr OpenProcess(uint access, bool inherit, uint pid);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)] static extern bool QueryFullProcessImageName(IntPtr h, uint flags, StringBuilder sb, ref uint size);
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
[DllImport("kernel32.dll")] static extern uint WTSGetActiveConsoleSessionId();
[DllImport("kernel32.dll")] static extern bool ProcessIdToSessionId(uint pid, out uint session);
[DllImport("kernel32.dll")] static extern uint SetThreadExecutionState(uint flags);
/** Keeps the PC awake and the display on while this helper runs (it reverts when the helper exits). */
public static bool StayAwake(bool on) {
// ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED
uint flags = on ? 0x80000000u | 0x00000001u | 0x00000002u : 0x80000000u;
return SetThreadExecutionState(flags) != 0;
}
public static string Init() {
try {
// Per-monitor aware v2: every rectangle and click is in physical pixels.
if (SetProcessDpiAwarenessContext(new IntPtr(-4))) return "per-monitor-v2";
return "unchanged";
} catch (Exception e) {
return "unavailable: " + e.Message;
}
}
public static string ProcessPath(uint pid) {
IntPtr h = OpenProcess(0x1000, false, pid);
if (h == IntPtr.Zero) return null;
try {
var sb = new StringBuilder(1024);
uint size = (uint)sb.Capacity;
return QueryFullProcessImageName(h, 0, sb, ref size) ? sb.ToString() : null;
} finally {
CloseHandle(h);
}
}
static bool Under(string path, string dir) {
if (String.IsNullOrEmpty(dir)) return true;
if (path == null) return false;
string prefix = dir.TrimEnd('\\') + "\\";
return path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
}
public static VashWindow Describe(IntPtr h) {
uint pid;
GetWindowThreadProcessId(h, out pid);
var title = new StringBuilder(GetWindowTextLength(h) + 1);
GetWindowText(h, title, title.Capacity);
var cls = new StringBuilder(256);
GetClassName(h, cls, cls.Capacity);
RECT r, c;
GetWindowRect(h, out r);
GetClientRect(h, out c);
var origin = new POINT();
ClientToScreen(h, ref origin);
string path = ProcessPath(pid);
return new VashWindow {
hwnd = h.ToInt64(), pid = (int)pid, title = title.ToString(), className = cls.ToString(),
exe = path, visible = IsWindowVisible(h), minimized = IsIconic(h),
x = r.Left, y = r.Top, width = r.Right - r.Left, height = r.Bottom - r.Top,
clientX = origin.X, clientY = origin.Y, clientWidth = c.Right - c.Left, clientHeight = c.Bottom - c.Top
};
}
/** Top-level windows of processes whose executable is under dir (all when empty). */
public static VashWindow[] Windows(string dir) {
var handles = new List<IntPtr>();
EnumWindows((h, l) => { handles.Add(h); return true; }, IntPtr.Zero);
var result = new List<VashWindow>();
var paths = new Dictionary<uint, string>();
foreach (var h in handles) {
uint pid;
GetWindowThreadProcessId(h, out pid);
string path;
if (!paths.TryGetValue(pid, out path)) { path = ProcessPath(pid); paths[pid] = path; }
if (!Under(path, dir)) continue;
result.Add(Describe(h));
}
return result.ToArray();
}
public static VashWindow Window(long hwnd) {
var h = new IntPtr(hwnd);
return IsWindow(h) ? Describe(h) : null;
}
public static VashProcess[] Processes(string dir) {
var result = new List<VashProcess>();
foreach (var p in Process.GetProcesses()) {
try {
string path = ProcessPath((uint)p.Id);
if (path != null && Under(path, dir)) result.Add(new VashProcess { pid = p.Id, path = path });
} catch { }
finally { p.Dispose(); }
}
return result.ToArray();
}
public static int Kill(int[] pids) {
int killed = 0;
foreach (int pid in pids) {
try { using (var p = Process.GetProcessById(pid)) { p.Kill(); killed++; } } catch { }
}
return killed;
}
/** Asks every top-level window of a process to close, like clicking its X. */
public static int CloseProcessWindows(int pid) {
int posted = 0;
foreach (var w in Windows(null)) {
if (w.pid != pid) continue;
if (PostMessage(new IntPtr(w.hwnd), 0x0010, IntPtr.Zero, IntPtr.Zero)) posted++;
}
return posted;
}
/** "Default" when the desktop is unlocked; "Winlogon" (or null) when locked or on a secure screen. */
public static string InputDesktop() {
IntPtr d = OpenInputDesktop(0, false, 0x0001);
if (d == IntPtr.Zero) return null;
try {
var sb = new StringBuilder(256);
int needed;
return GetUserObjectInformation(d, 2, sb, sb.Capacity * 2, out needed) ? sb.ToString() : null;
} finally {
CloseDesktop(d);
}
}
public static Dictionary<string, object> Session() {
uint session;
ProcessIdToSessionId((uint)Process.GetCurrentProcess().Id, out session);
var info = new LASTINPUTINFO { cbSize = (uint)Marshal.SizeOf(typeof(LASTINPUTINFO)) };
GetLastInputInfo(ref info);
return new Dictionary<string, object> {
{ "sessionId", (int)session },
{ "consoleSessionId", (int)WTSGetActiveConsoleSessionId() },
{ "inputDesktop", InputDesktop() },
{ "screenWidth", GetSystemMetrics(0) },
{ "screenHeight", GetSystemMetrics(1) },
{ "idleMs", unchecked(Environment.TickCount - (int)info.dwTime) }
};
}
static void Key(ushort vk, bool up) {
var input = new INPUT { type = 1 };
input.U.ki.wVk = vk;
input.U.ki.dwFlags = up ? 0x0002u : 0u;
SendInput(1, new[] { input }, Marshal.SizeOf(typeof(INPUT)));
}
/** Brings a window to the front; Windows only allows this in some cases, so try the known ways. */
public static bool Focus(long hwnd) {
var h = new IntPtr(hwnd);
if (!IsWindow(h)) return false;
if (IsIconic(h)) ShowWindow(h, 9);
if (GetForegroundWindow() == h) return true;
IntPtr fg = GetForegroundWindow();
uint ignored;
uint fgThread = fg == IntPtr.Zero ? 0 : GetWindowThreadProcessId(fg, out ignored);
uint me = GetCurrentThreadId();
bool attached = fgThread != 0 && fgThread != me && AttachThreadInput(me, fgThread, true);
BringWindowToTop(h);
SetForegroundWindow(h);
if (attached) AttachThreadInput(me, fgThread, false);
Thread.Sleep(80);
if (GetForegroundWindow() == h) return true;
// The process that sent the last input may set the foreground window: tap Alt, then retry.
Key(0x12, false);
Key(0x12, true);
SetForegroundWindow(h);
Thread.Sleep(120);
return GetForegroundWindow() == h;
}
public static bool Move(long hwnd, int x, int y) {
// SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE
return SetWindowPos(new IntPtr(hwnd), IntPtr.Zero, x, y, 0, 0, 0x0001 | 0x0004 | 0x0010);
}
public static bool SetTitle(long hwnd, string title) {
IntPtr result;
// WM_SETTEXT with a timeout, so a busy window cannot hang the helper.
return SendMessageTimeout(new IntPtr(hwnd), 0x000C, IntPtr.Zero, title, 0x0002, 2000, out result) != IntPtr.Zero;
}
static ImageCodecInfo Codec(string mime) {
foreach (var codec in ImageCodecInfo.GetImageEncoders()) if (codec.MimeType == mime) return codec;
return null;
}
static void Save(Bitmap bmp, string path, string format, long quality) {
if (format == "jpeg") {
var parameters = new EncoderParameters(1);
parameters.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, quality);
bmp.Save(path, Codec("image/jpeg"), parameters);
} else {
bmp.Save(path, ImageFormat.Png);
}
}
/**
* Copies a screen rectangle to an image file, optionally scaled (to at most maxWidth,
* or by scale) and inverted (dark text on light reads better for OCR). Returns
* the saved size. Fails while the desktop is locked.
*/
public static int[] Capture(int x, int y, int w, int h, string path, string format, int maxWidth, double scale, bool invert, long quality) {
using (var bmp = new Bitmap(w, h, PixelFormat.Format32bppArgb)) {
using (var g = Graphics.FromImage(bmp)) {
g.CopyFromScreen(x, y, 0, 0, new Size(w, h), CopyPixelOperation.SourceCopy);
}
double factor = scale > 0 ? scale : 1.0;
if (maxWidth > 0 && w * factor > maxWidth) factor = (double)maxWidth / w;
int ow = Math.Max(1, (int)Math.Round(w * factor));
int oh = Math.Max(1, (int)Math.Round(h * factor));
if (ow == w && oh == h && !invert) {
Save(bmp, path, format, quality);
return new[] { ow, oh };
}
using (var output = new Bitmap(ow, oh, PixelFormat.Format24bppRgb)) {
using (var g = Graphics.FromImage(output)) {
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
var attributes = new ImageAttributes();
if (invert) {
attributes.SetColorMatrix(new ColorMatrix(new float[][] {
new float[] { -1, 0, 0, 0, 0 },
new float[] { 0, -1, 0, 0, 0 },
new float[] { 0, 0, -1, 0, 0 },
new float[] { 0, 0, 0, 1, 0 },
new float[] { 1, 1, 1, 0, 1 }
}));
}
g.DrawImage(bmp, new Rectangle(0, 0, ow, oh), 0, 0, w, h, GraphicsUnit.Pixel, attributes);
}
Save(output, path, format, quality);
}
return new[] { ow, oh };
}
}
public static bool Click(int x, int y, bool right) {
if (!SetCursorPos(x, y)) return false;
Thread.Sleep(60);
var down = new INPUT { type = 0 };
down.U.mi.dwFlags = right ? 0x0008u : 0x0002u;
var up = new INPUT { type = 0 };
up.U.mi.dwFlags = right ? 0x0010u : 0x0004u;
uint sent = SendInput(1, new[] { down }, Marshal.SizeOf(typeof(INPUT)));
Thread.Sleep(60);
sent += SendInput(1, new[] { up }, Marshal.SizeOf(typeof(INPUT)));
return sent == 2;
}
public static int TypeText(string text) {
int sent = 0;
foreach (char ch in text) {
var down = new INPUT { type = 1 };
down.U.ki.wScan = ch;
down.U.ki.dwFlags = 0x0004;
var up = new INPUT { type = 1 };
up.U.ki.wScan = ch;
up.U.ki.dwFlags = 0x0004 | 0x0002;
sent += (int)SendInput(1, new[] { down }, Marshal.SizeOf(typeof(INPUT)));
sent += (int)SendInput(1, new[] { up }, Marshal.SizeOf(typeof(INPUT)));
Thread.Sleep(15);
}
return sent / 2;
}
public static void PressKey(ushort vk) {
Key(vk, false);
Thread.Sleep(30);
Key(vk, true);
}
}
'@
$script:dpi = [VashDesktop]::Init()
# ── Windows OCR (Windows.Media.Ocr, part of Windows 10 and 11) ─────────────
$script:ocr = $null
function Initialize-Ocr {
if ($null -ne $script:ocr) { return }
Add-Type -AssemblyName System.Runtime.WindowsRuntime
$null = [Windows.Storage.StorageFile, Windows.Storage, ContentType = WindowsRuntime]
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType = WindowsRuntime]
$null = [Windows.Foundation.IAsyncOperation`1, Windows.Foundation, ContentType = WindowsRuntime]
$null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType = WindowsRuntime]
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType = WindowsRuntime]
$null = [Windows.Storage.Streams.RandomAccessStream, Windows.Storage.Streams, ContentType = WindowsRuntime]
$script:asTask = [System.WindowsRuntimeSystemExtensions].GetMethods() |
Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' } |
Select-Object -First 1
$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
if ($null -eq $engine) {
try {
$null = [Windows.Globalization.Language, Windows.Globalization, ContentType = WindowsRuntime]
$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromLanguage([Windows.Globalization.Language]::new('en-US'))
} catch { }
}
if ($null -eq $engine) {
throw 'Windows OCR has no language available. Add English under Settings > Time & language > Language & region.'
}
$script:ocr = $engine
}
function Await($operation, [Type]$type) {
$task = $script:asTask.MakeGenericMethod($type).Invoke($null, @($operation))
if (-not $task.Wait(20000)) { throw 'Windows OCR timed out' }
$task.Result
}
function Read-Text([string]$path) {
Initialize-Ocr
$file = Await ([Windows.Storage.StorageFile]::GetFileFromPathAsync($path)) ([Windows.Storage.StorageFile])
$stream = Await ($file.OpenAsync([Windows.Storage.FileAccessMode]::Read)) ([Windows.Storage.Streams.IRandomAccessStream])
try {
$decoder = Await ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($stream)) ([Windows.Graphics.Imaging.BitmapDecoder])
$bitmap = Await ($decoder.GetSoftwareBitmapAsync()) ([Windows.Graphics.Imaging.SoftwareBitmap])
$result = Await ($script:ocr.RecognizeAsync($bitmap)) ([Windows.Media.Ocr.OcrResult])
} finally {
$stream.Dispose()
}
$lines = New-Object System.Collections.ArrayList
foreach ($line in $result.Lines) {
$left = [double]::MaxValue; $top = [double]::MaxValue; $right = 0.0; $bottom = 0.0
foreach ($word in $line.Words) {
$r = $word.BoundingRect
$left = [Math]::Min($left, $r.X); $top = [Math]::Min($top, $r.Y)
$right = [Math]::Max($right, $r.X + $r.Width); $bottom = [Math]::Max($bottom, $r.Y + $r.Height)
}
if ($right -le 0) { continue }
$null = $lines.Add(@{ text = $line.Text; x = $left; y = $top; width = $right - $left; height = $bottom - $top })
}
return , $lines.ToArray()
}
# ── Request loop ──────────────────────────────────────────────────────────
function Reply($id, [bool]$ok, $value) {
$message = @{ id = $id; ok = $ok }
if ($ok) { $message.result = $value } else { $message.error = [string]$value }
$stdout.WriteLine((ConvertTo-Json -InputObject $message -Compress -Depth 6))
}
Reply 0 $true @{ ready = $true; dpi = $script:dpi; powershell = $PSVersionTable.PSVersion.ToString() }
while ($true) {
$line = $stdin.ReadLine()
if ($null -eq $line) { break }
if (-not $line.Trim()) { continue }
$id = $null
try {
$request = ConvertFrom-Json $line
$id = $request.id
$cmd = [string]$request.cmd
$result = $null
if ($cmd -eq 'ping') {
$result = @{ pong = $true }
} elseif ($cmd -eq 'awake') {
$result = [VashDesktop]::StayAwake([bool]$request.on)
} elseif ($cmd -eq 'session') {
$result = [VashDesktop]::Session()
} elseif ($cmd -eq 'windows') {
$result = [VashDesktop]::Windows([string]$request.under)
} elseif ($cmd -eq 'window') {
$result = [VashDesktop]::Window([long]$request.hwnd)
} elseif ($cmd -eq 'processes') {
$result = [VashDesktop]::Processes([string]$request.under)
} elseif ($cmd -eq 'kill') {
$result = [VashDesktop]::Kill([int[]]@($request.pids))
} elseif ($cmd -eq 'close') {
$result = [VashDesktop]::CloseProcessWindows([int]$request.pid)
} elseif ($cmd -eq 'focus') {
$result = [VashDesktop]::Focus([long]$request.hwnd)
} elseif ($cmd -eq 'move') {
$result = [VashDesktop]::Move([long]$request.hwnd, [int]$request.x, [int]$request.y)
} elseif ($cmd -eq 'title') {
$result = [VashDesktop]::SetTitle([long]$request.hwnd, [string]$request.title)
} elseif ($cmd -eq 'capture') {
$format = if ($request.format) { [string]$request.format } else { 'png' }
$quality = if ($request.quality) { [long]$request.quality } else { 80 }
$size = [VashDesktop]::Capture([int]$request.x, [int]$request.y, [int]$request.width, [int]$request.height,
[string]$request.path, $format, [int]$request.maxWidth, [double]$request.scale, [bool]$request.invert, $quality)
$result = @{ width = $size[0]; height = $size[1] }
} elseif ($cmd -eq 'ocr') {
$result = Read-Text ([string]$request.path)
} elseif ($cmd -eq 'ocrReady') {
Initialize-Ocr
$result = @{ language = $script:ocr.RecognizerLanguage.LanguageTag; maxDimension = [Windows.Media.Ocr.OcrEngine]::MaxImageDimension }
} elseif ($cmd -eq 'click') {
$result = [VashDesktop]::Click([int]$request.x, [int]$request.y, [bool]$request.right)
} elseif ($cmd -eq 'type') {
$result = [VashDesktop]::TypeText([string]$request.text)
} elseif ($cmd -eq 'key') {
[VashDesktop]::PressKey([uint16]$request.vk)
$result = $true
} else {
throw "Unknown command $cmd"
}
Reply $id $true $result
} catch {
Reply $id $false $_.Exception.Message
}
}

278
streamer/windows/desktop.ts Normal file
View File

@@ -0,0 +1,278 @@
/**
* The desktop the worker drives: windows, screen capture, OCR and input.
* On Windows this is a long-running PowerShell helper (desktop.ps1) in the
* signed-in session; tests use a fake with the same interface.
*/
import { existsSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import type { Subprocess } from 'bun';
import helperScript from './desktop.ps1' with { type: 'text' };
export interface WindowInfo {
hwnd: number;
pid: number;
title: string;
className: string;
exe: string | null;
visible: boolean;
minimized: boolean;
x: number;
y: number;
width: number;
height: number;
/** The client area (no title bar or borders) in screen coordinates. */
clientX: number;
clientY: number;
clientWidth: number;
clientHeight: number;
}
export interface SessionInfo {
sessionId: number;
consoleSessionId: number;
/** "Default" when unlocked; "Winlogon" or null when locked or on a secure screen. */
inputDesktop: string | null;
screenWidth: number;
screenHeight: number;
idleMs: number;
}
export interface OcrLine {
text: string;
x: number;
y: number;
width: number;
height: number;
}
export interface CaptureOptions {
x: number;
y: number;
width: number;
height: number;
path: string;
format?: 'png' | 'jpeg';
maxWidth?: number;
scale?: number;
invert?: boolean;
quality?: number;
}
export interface Desktop {
session(): Promise<SessionInfo>;
/** Top-level windows of processes whose executable is under a folder. */
windows(under: string): Promise<WindowInfo[]>;
window(hwnd: number): Promise<WindowInfo | null>;
processes(under: string): Promise<{ pid: number; path: string }[]>;
kill(pids: number[]): Promise<number>;
/** Posts WM_CLOSE to a process's windows. */
close(pid: number): Promise<number>;
focus(hwnd: number): Promise<boolean>;
move(hwnd: number, x: number, y: number): Promise<boolean>;
setTitle(hwnd: number, title: string): Promise<boolean>;
capture(options: CaptureOptions): Promise<{ width: number; height: number }>;
ocr(path: string): Promise<OcrLine[]>;
ocrReady(): Promise<{ language: string; maxDimension: number }>;
click(x: number, y: number, right?: boolean): Promise<boolean>;
type(text: string): Promise<number>;
key(vk: number): Promise<void>;
/** Keeps the PC from sleeping and the display on while the helper runs. */
stayAwake(on: boolean): Promise<boolean>;
stop(): void;
}
interface Pending {
resolve(value: unknown): void;
reject(err: Error): void;
timer: ReturnType<typeof setTimeout>;
}
export class WindowsDesktop implements Desktop {
private process: Subprocess<'pipe', 'pipe', 'pipe'> | null = null;
private pending = new Map<number, Pending>();
private nextId = 1;
private ready: Promise<void> | null = null;
private lastError = '';
constructor(
private scriptPath: string,
private log: (...parts: unknown[]) => void = () => {}
) {}
/**
* Writes the helper next to the program when it differs from this build's copy,
* with a byte order mark: Windows PowerShell reads a script without one as ANSI.
*/
static async writeScript(path: string) {
const content = `\uFEFF${helperScript.replace(/^\uFEFF/, '')}`;
const current = existsSync(path) ? await readFile(path, 'utf8') : null;
if (current === content) return;
await mkdir(dirname(path), { recursive: true });
await writeFile(path, content);
}
private start(): Promise<void> {
if (this.ready) return this.ready;
this.ready = (async () => {
await WindowsDesktop.writeScript(this.scriptPath);
const process = Bun.spawn(
[
'powershell.exe',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
this.scriptPath
],
{ stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', windowsHide: true }
);
this.process = process;
let resolveReady!: () => void;
let rejectReady!: (err: Error) => void;
const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
this.pending.set(0, {
resolve: () => resolveReady(),
reject: rejectReady,
timer: setTimeout(() => rejectReady(new Error('The desktop helper did not start')), 60_000)
});
void this.readLines(process);
void (async () => {
for await (const chunk of process.stderr)
this.lastError = new TextDecoder().decode(chunk).trim().slice(-500) || this.lastError;
})();
void process.exited.then((code) => {
if (this.process !== process) return;
this.process = null;
this.ready = null;
const error = new Error(
`The desktop helper exited (${code})${this.lastError ? `: ${this.lastError}` : ''}`
);
for (const waiter of this.pending.values()) {
clearTimeout(waiter.timer);
waiter.reject(error);
}
this.pending.clear();
});
await ready;
})();
this.ready.catch(() => {
this.ready = null;
this.process?.kill();
this.process = null;
});
return this.ready;
}
private async readLines(process: Subprocess<'pipe', 'pipe', 'pipe'>) {
let buffer = '';
const decoder = new TextDecoder();
for await (const chunk of process.stdout) {
buffer += decoder.decode(chunk, { stream: true });
let newline: number;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline).trim();
buffer = buffer.slice(newline + 1);
if (!line) continue;
let message: { id: number; ok: boolean; result?: unknown; error?: string };
try {
message = JSON.parse(line.replace(/^\uFEFF/, ''));
} catch {
this.log('desktop helper:', line.slice(0, 200));
continue;
}
const waiter = this.pending.get(message.id);
if (!waiter) continue;
this.pending.delete(message.id);
clearTimeout(waiter.timer);
if (message.ok) waiter.resolve(message.result ?? null);
else waiter.reject(new Error(message.error ?? 'desktop helper error'));
}
}
}
async call<T>(cmd: string, args: Record<string, unknown> = {}, timeoutMs = 20_000): Promise<T> {
await this.start();
const process = this.process;
if (!process) throw new Error('The desktop helper is not running');
const id = this.nextId++;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
// A stuck helper is restarted on the next call.
this.process = null;
this.ready = null;
process.kill();
reject(new Error(`The desktop helper did not answer ${cmd} in time`));
}, timeoutMs);
this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer });
process.stdin.write(`${JSON.stringify({ id, cmd, ...args })}\n`);
void process.stdin.flush();
});
}
session() {
return this.call<SessionInfo>('session');
}
windows(under: string) {
return this.call<WindowInfo[]>('windows', { under });
}
window(hwnd: number) {
return this.call<WindowInfo | null>('window', { hwnd });
}
processes(under: string) {
return this.call<{ pid: number; path: string }[]>('processes', { under });
}
kill(pids: number[]) {
return pids.length ? this.call<number>('kill', { pids }) : Promise.resolve(0);
}
close(pid: number) {
return this.call<number>('close', { pid });
}
focus(hwnd: number) {
return this.call<boolean>('focus', { hwnd });
}
move(hwnd: number, x: number, y: number) {
return this.call<boolean>('move', { hwnd, x, y });
}
setTitle(hwnd: number, title: string) {
return this.call<boolean>('title', { hwnd, title });
}
capture(options: CaptureOptions) {
return this.call<{ width: number; height: number }>('capture', { ...options });
}
ocr(path: string) {
return this.call<OcrLine[]>('ocr', { path }, 40_000);
}
ocrReady() {
return this.call<{ language: string; maxDimension: number }>('ocrReady', {}, 40_000);
}
click(x: number, y: number, right = false) {
return this.call<boolean>('click', { x, y, right });
}
type(text: string) {
return this.call<number>('type', { text });
}
async key(vk: number) {
await this.call('key', { vk });
}
stayAwake(on: boolean) {
return this.call<boolean>('awake', { on });
}
stop() {
const process = this.process;
this.process = null;
this.ready = null;
try {
process?.stdin.end();
} catch {
/* already gone */
}
setTimeout(() => process?.kill(), 1000);
}
}

122
streamer/windows/fakes.ts Normal file
View File

@@ -0,0 +1,122 @@
/** Test doubles for the desktop helper and tosu. */
import type { TosuFeed, TosuFrame } from '../tosu';
import type { CaptureOptions, Desktop, OcrLine, SessionInfo, WindowInfo } from './desktop';
export function windowInfo(overrides: Partial<WindowInfo> = {}): WindowInfo {
return {
hwnd: 100,
pid: 10,
title: 'osu!',
className: 'WindowsForms10.Window.8.app.0.1',
exe: 'C:\\vash\\osu\\osu!.exe',
visible: true,
minimized: false,
x: 0,
y: 0,
width: 1296,
height: 959,
clientX: 8,
clientY: 31,
clientWidth: 1280,
clientHeight: 920,
...overrides
};
}
export class FakeDesktop implements Desktop {
sessionInfo: SessionInfo = {
sessionId: 1,
consoleSessionId: 1,
inputDesktop: 'Default',
screenWidth: 1920,
screenHeight: 1080,
idleMs: 0
};
running: { pid: number; path: string }[] = [];
windowList: WindowInfo[] = [];
ocrLines: OcrLine[] = [];
calls: { cmd: string; args: unknown[] }[] = [];
focusResult = true;
private note(cmd: string, ...args: unknown[]) {
this.calls.push({ cmd, args });
}
called(cmd: string) {
return this.calls.filter((call) => call.cmd === cmd);
}
async session() {
return this.sessionInfo;
}
async windows(under: string) {
this.note('windows', under);
return this.windowList;
}
async window(hwnd: number) {
return this.windowList.find((window) => window.hwnd === hwnd) ?? null;
}
async processes(under: string) {
return this.running.filter((process) =>
process.path.toLowerCase().startsWith(under.toLowerCase())
);
}
async kill(pids: number[]) {
this.note('kill', pids);
this.running = this.running.filter((process) => !pids.includes(process.pid));
return pids.length;
}
async close(pid: number) {
this.note('close', pid);
this.running = this.running.filter((process) => process.pid !== pid);
this.windowList = this.windowList.filter((window) => window.pid !== pid);
return 1;
}
async focus(hwnd: number) {
this.note('focus', hwnd);
return this.focusResult;
}
async move(hwnd: number, x: number, y: number) {
this.note('move', hwnd, x, y);
return true;
}
async setTitle(hwnd: number, title: string) {
this.note('title', hwnd, title);
const window = this.windowList.find((each) => each.hwnd === hwnd);
if (window) window.title = title;
return true;
}
async capture(options: CaptureOptions) {
this.note('capture', options);
const scale = options.scale ?? 1;
return { width: Math.round(options.width * scale), height: Math.round(options.height * scale) };
}
async ocr(path: string) {
this.note('ocr', path);
return this.ocrLines;
}
async ocrReady() {
return { language: 'en-US', maxDimension: 2600 };
}
async click(x: number, y: number) {
this.note('click', x, y);
return true;
}
async type(text: string) {
return text.length;
}
async key() {}
async stayAwake() {
return true;
}
stop() {}
}
/** Stands in for TosuFeed: whatever frame the test sets is the fresh one. */
export function fakeTosu(frame: TosuFrame | null = null) {
const feed = { frame, fresh: () => feed.frame };
return feed as unknown as TosuFeed & { frame: TosuFrame | null };
}
export function tourneyFrame(left: string, right: string): TosuFrame {
return { ipcState: 1, beatmapId: null, progress: null, teams: { left, right }, clients: [] };
}

381
streamer/windows/install.ts Normal file
View 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';
}

68
streamer/windows/lock.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* One osu!tourney + tosu stack per PC. tosu reads every osu! process on the
* machine and serves the first tourney manager it finds, so two instances
* running osu!tourney at once would mix up their scores and rooms. An
* instance holds this lock while it runs the stack; others wait and say why.
*/
import { open, readFile, rm } from 'node:fs/promises';
export interface LockOwner {
instance: string;
pid: number;
at: string;
}
function alive(pid: number) {
try {
process.kill(pid, 0);
return true;
} catch (err) {
// EPERM: exists but belongs to someone else.
return (err as NodeJS.ErrnoException).code === 'EPERM';
}
}
export async function lockOwner(path: string): Promise<LockOwner | null> {
try {
const owner = JSON.parse(await readFile(path, 'utf8')) as LockOwner;
return owner && alive(owner.pid) ? owner : null;
} catch {
return null;
}
}
/** Takes the lock for this process, or returns who holds it. */
export async function acquireLock(
path: string,
instance: string,
pid = process.pid
): Promise<{ ok: true } | { ok: false; owner: LockOwner }> {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const file = await open(path, 'wx');
await file.writeFile(JSON.stringify({ instance, pid, at: new Date().toISOString() }));
await file.close();
return { ok: true };
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
}
const owner = await lockOwner(path);
if (owner && owner.pid === pid) return { ok: true };
if (owner) return { ok: false, owner };
// The holder is gone; clear its lock and try again.
await rm(path, { force: true });
}
const owner = await lockOwner(path);
return owner
? { ok: false, owner }
: { ok: false, owner: { instance: 'unknown', pid: 0, at: '' } };
}
export async function releaseLock(path: string, pid = process.pid) {
try {
const owner = JSON.parse(await readFile(path, 'utf8')) as LockOwner;
if (owner.pid === pid) await rm(path, { force: true });
} catch {
/* not ours or already gone */
}
}

40
streamer/windows/log.ts Normal file
View File

@@ -0,0 +1,40 @@
/** Appends timestamped lines to the instance's log, keeping the last few files. */
import { appendFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
import { dirname } from 'node:path';
const MAX_BYTES = 10 * 1024 * 1024;
const KEEP = 5;
/** Rotation keeps a multi-hour broadcast's log bounded: worker.log, worker.1.log, … */
function rotate(path: string) {
if (!existsSync(path) || statSync(path).size < MAX_BYTES) return;
const numbered = (n: number) => path.replace(/\.log$/, `.${n}.log`);
rmSync(numbered(KEEP), { force: true });
for (let n = KEEP - 1; n >= 1; n--)
if (existsSync(numbered(n))) renameSync(numbered(n), numbered(n + 1));
renameSync(path, numbered(1));
}
export function fileLogger(path: string, alsoConsole = false) {
mkdirSync(dirname(path), { recursive: true });
let lines = 0;
return (...parts: unknown[]) => {
const text = parts
.map((part) =>
part instanceof Error
? part.message
: typeof part === 'string'
? part
: JSON.stringify(part)
)
.join(' ');
const line = `${new Date().toISOString()} ${text}\n`;
if (alsoConsole) process.stdout.write(line);
try {
if (++lines % 200 === 0) rotate(path);
appendFileSync(path, line);
} catch {
/* a full or locked disk must not stop the broadcast */
}
};
}

View File

@@ -0,0 +1,313 @@
/**
* Keeps the instance's OBS scene as the broadcast needs it, over obs-websocket:
*
* scene "Vash"
* Overlay browser source, the channel's overlay at canvas size (on top)
* Gameplay window capture of this instance's osu!tourney window, with its
* audio, the control panel cropped off, fitted to the canvas;
* hidden until the requested room is being spectated
*
* Every call is idempotent: it creates what is missing and corrects what
* drifted, so it runs after every OBS start and whenever something changes.
*/
import type { Obs } from '../obs';
import { gameplayHeight } from './tourney';
export const SCENE = 'Vash';
export const GAMEPLAY = 'Gameplay';
export const OVERLAY = 'Overlay';
/** OBS's window setting: "title:class:exe" with '#' and ':' escaped (libobs window-helpers.c). */
export function windowSetting(target: { title: string; className: string; exe: string }) {
const encode = (value: string) => value.replace(/#/g, '#22').replace(/:/g, '#3A');
return `${encode(target.title)}:${encode(target.className)}:${encode(target.exe)}`;
}
/** Match the window by exact title only; another osu! window can never be picked up instead. */
const WINDOW_PRIORITY_TITLE = 1;
const METHOD_WGC = 2;
const OBS_ALIGN_TOP_LEFT = 5;
export interface SceneTarget {
canvas: { width: number; height: number };
fps: number;
overlayUrl: string;
/** The tourney window to capture, when one exists. */
capture: { title: string; className: string; exe: string } | null;
showGameplay: boolean;
ingest: { server: string; key: string };
}
interface SceneItem {
sceneItemId: number;
sourceName: string;
sceneItemIndex: number;
sceneItemEnabled: boolean;
}
interface Transform {
sourceWidth: number;
sourceHeight: number;
cropBottom: number;
boundsType: string;
boundsWidth: number;
boundsHeight: number;
}
const differs = (current: Record<string, unknown>, wanted: Record<string, unknown>) =>
Object.entries(wanted).some(
([key, value]) => JSON.stringify(current[key]) !== JSON.stringify(value)
);
export class ObsScene {
private lastFull = '';
constructor(
private obs: Obs,
private log: (...parts: unknown[]) => void
) {}
/** Creates or corrects the scene; cheap when nothing changed. */
async reconcile(target: SceneTarget) {
const key = JSON.stringify({ ...target, showGameplay: undefined });
if (key !== this.lastFull) {
await this.full(target);
this.lastFull = key;
}
await this.fitGameplay(target);
}
/** Forget what was applied, e.g. after OBS restarted. */
reset() {
this.lastFull = '';
}
private async full(target: SceneTarget) {
const obs = this.obs;
const { scenes, currentProgramSceneName } = await obs.request<{
scenes: { sceneName: string }[];
currentProgramSceneName: string;
}>('GetSceneList');
if (!scenes.some((scene) => scene.sceneName === SCENE)) {
await obs.request('CreateScene', { sceneName: SCENE });
this.log(`OBS: created scene ${SCENE}`);
}
if (currentProgramSceneName !== SCENE)
await obs.request('SetCurrentProgramScene', { sceneName: SCENE });
const { inputs } = await obs.request<{ inputs: { inputName: string }[] }>('GetInputList');
const names = new Set(inputs.map((input) => input.inputName));
const gameplaySettings = {
window: target.capture ? windowSetting(target.capture) : '',
priority: WINDOW_PRIORITY_TITLE,
method: METHOD_WGC,
cursor: false,
client_area: true,
capture_audio: true,
compatibility: false
};
const overlaySettings = {
url: target.overlayUrl,
width: target.canvas.width,
height: target.canvas.height,
fps_custom: true,
fps: target.fps,
reroute_audio: true,
shutdown: false,
restart_when_active: false
};
await this.ensureInput(
names,
GAMEPLAY,
'window_capture',
gameplaySettings,
target.showGameplay
);
await this.ensureInput(names, OVERLAY, 'browser_source', overlaySettings, true);
// Both in the scene, the overlay above gameplay.
let items = await this.items();
for (const name of [GAMEPLAY, OVERLAY])
if (!items.some((item) => item.sourceName === name))
await obs.request('CreateSceneItem', { sceneName: SCENE, sourceName: name });
items = await this.items();
const overlay = items.find((item) => item.sourceName === OVERLAY)!;
const top = Math.max(...items.map((item) => item.sceneItemIndex));
if (overlay.sceneItemIndex !== top)
await obs.request('SetSceneItemIndex', {
sceneName: SCENE,
sceneItemId: overlay.sceneItemId,
sceneItemIndex: top
});
await obs.request('SetSceneItemTransform', {
sceneName: SCENE,
sceneItemId: overlay.sceneItemId,
sceneItemTransform: {
positionX: 0,
positionY: 0,
alignment: OBS_ALIGN_TOP_LEFT,
boundsType: 'OBS_BOUNDS_NONE',
scaleX: 1,
scaleY: 1,
rotation: 0
}
});
if (!overlay.sceneItemEnabled)
await obs.request('SetSceneItemEnabled', {
sceneName: SCENE,
sceneItemId: overlay.sceneItemId,
sceneItemEnabled: true
});
// Only the capture's own audio and the overlay: never the PC's desktop audio or microphone.
const special = await obs.request<Record<string, string | null>>('GetSpecialInputs');
for (const name of Object.values(special))
if (name)
await obs.request('SetInputMute', { inputName: name, inputMuted: true }).catch(() => {});
const service = await obs
.request<{ streamServiceType: string; streamServiceSettings: Record<string, unknown> }>(
'GetStreamServiceSettings'
)
.catch(() => null);
const wanted = { server: target.ingest.server, key: target.ingest.key };
if (
!service ||
service.streamServiceType !== 'rtmp_custom' ||
differs(service.streamServiceSettings, wanted)
)
await obs.request('SetStreamServiceSettings', {
streamServiceType: 'rtmp_custom',
streamServiceSettings: { ...wanted, use_auth: false, bwtest: false }
});
}
private async ensureInput(
names: Set<string>,
inputName: string,
inputKind: string,
inputSettings: Record<string, unknown>,
enabled: boolean
) {
const obs = this.obs;
if (!names.has(inputName)) {
await obs.request('CreateInput', {
sceneName: SCENE,
inputName,
inputKind,
inputSettings,
sceneItemEnabled: enabled
});
this.log(`OBS: added ${inputName}`);
return;
}
const current = await obs.request<{
inputKind: string;
inputSettings: Record<string, unknown>;
}>('GetInputSettings', { inputName });
if (current.inputKind !== inputKind) {
// Someone replaced it with another kind of source; start over.
await obs.request('RemoveInput', { inputName });
await obs.request('CreateInput', {
sceneName: SCENE,
inputName,
inputKind,
inputSettings,
sceneItemEnabled: enabled
});
this.log(`OBS: replaced ${inputName}`);
return;
}
if (differs(current.inputSettings, inputSettings))
await obs.request('SetInputSettings', { inputName, inputSettings, overlay: true });
}
private async items() {
const { sceneItems } = await this.obs.request<{ sceneItems: SceneItem[] }>('GetSceneItemList', {
sceneName: SCENE
});
return sceneItems;
}
/** Crops the control panel off the capture and fits the gameplay to the canvas; shows or hides it. */
private async fitGameplay(target: SceneTarget) {
const obs = this.obs;
const item = (await this.items()).find((each) => each.sourceName === GAMEPLAY);
if (!item) return;
if (item.sceneItemEnabled !== target.showGameplay)
await obs.request('SetSceneItemEnabled', {
sceneName: SCENE,
sceneItemId: item.sceneItemId,
sceneItemEnabled: target.showGameplay
});
const { sceneItemTransform: transform } = await obs.request<{ sceneItemTransform: Transform }>(
'GetSceneItemTransform',
{ sceneName: SCENE, sceneItemId: item.sceneItemId }
);
if (!transform.sourceWidth || !transform.sourceHeight) return;
const cropBottom = Math.max(0, transform.sourceHeight - gameplayHeight(transform.sourceWidth));
if (
transform.cropBottom === cropBottom &&
transform.boundsType === 'OBS_BOUNDS_SCALE_INNER' &&
transform.boundsWidth === target.canvas.width &&
transform.boundsHeight === target.canvas.height
)
return;
await obs.request('SetSceneItemTransform', {
sceneName: SCENE,
sceneItemId: item.sceneItemId,
sceneItemTransform: {
positionX: 0,
positionY: 0,
alignment: OBS_ALIGN_TOP_LEFT,
cropTop: 0,
cropLeft: 0,
cropRight: 0,
cropBottom,
boundsType: 'OBS_BOUNDS_SCALE_INNER',
boundsAlignment: 0,
boundsWidth: target.canvas.width,
boundsHeight: target.canvas.height,
rotation: 0
}
});
this.log(
`OBS: gameplay ${transform.sourceWidth}×${transform.sourceHeight}, control panel cropped (${cropBottom} px)`
);
}
async streaming() {
const status = await this.obs.request<{ outputActive: boolean; outputReconnecting: boolean }>(
'GetStreamStatus'
);
return status;
}
async startStream() {
await this.obs.request('StartStream');
}
async stopStream() {
await this.obs.request('StopStream');
}
/** The profile's simple-mode encoder; OBS switches it to x264 by itself when AMF is unavailable. */
async profileEncoder() {
const { currentValue } = await this.obs.request<{ currentValue: string | null }>(
'GetProfileParameter',
{ parameterCategory: 'SimpleOutput', parameterName: 'StreamEncoder' }
);
return currentValue;
}
/** A PNG of the program output, for diagnostics. */
async screenshot(width = 1280) {
const { imageData } = await this.obs.request<{ imageData: string }>('GetSourceScreenshot', {
sourceName: SCENE,
imageFormat: 'png',
imageWidth: width
});
return Buffer.from(imageData.replace(/^data:image\/png;base64,/, ''), 'base64');
}
}

View File

@@ -0,0 +1,212 @@
/**
* Settings for an instance's portable OBS, written while OBS is closed:
* no first-run wizard or crash dialog, websocket on the instance's port with
* its own password, and a "Vash" profile with the channel's size, frame rate
* and bitrate, AMD AMF hardware encoding, and the local relay as its server.
* Keys OBS wrote itself are kept.
*/
import { existsSync } from 'node:fs';
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
export type Ini = Map<string, Map<string, string>>;
export function parseIni(text: string): Ini {
const ini: Ini = new Map();
let section = new Map<string, string>();
ini.set('', section);
for (const raw of text.replace(/^\uFEFF/, '').split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith(';') || line.startsWith('#')) continue;
const header = /^\[(.+)\]$/.exec(line);
if (header) {
section = ini.get(header[1]) ?? new Map();
ini.set(header[1], section);
continue;
}
const eq = line.indexOf('=');
if (eq > 0) section.set(line.slice(0, eq).trim(), line.slice(eq + 1).trim());
}
return ini;
}
export function stringifyIni(ini: Ini) {
const parts: string[] = [];
for (const [name, section] of ini) {
if (!section.size) continue;
if (name) parts.push(`[${name}]`);
for (const [key, value] of section) parts.push(`${key}=${value}`);
parts.push('');
}
return parts.join('\r\n');
}
export function setIni(
ini: Ini,
values: Record<string, Record<string, string | number | boolean>>
) {
for (const [name, entries] of Object.entries(values)) {
const section = ini.get(name) ?? new Map<string, string>();
ini.set(name, section);
for (const [key, value] of Object.entries(entries)) section.set(key, String(value));
}
return ini;
}
async function updateIni(
path: string,
values: Record<string, Record<string, string | number | boolean>>
) {
const existing = existsSync(path) ? await readFile(path, 'utf8') : '';
const next = stringifyIni(setIni(parseIni(existing), values));
await mkdir(join(path, '..'), { recursive: true });
if (next !== existing) await writeFile(path, next);
}
export const PROFILE = 'Vash';
export const COLLECTION = 'Vash';
export interface ObsSettings {
websocketPort: number;
websocketPassword: string;
width: number;
height: number;
fps: number;
bitrateKbps: number;
encoder: 'amd' | 'x264';
ingestUrl: string;
ingestKey: string;
}
/** Everything a fresh or existing portable OBS needs; safe to run before every start. */
export async function writeObsSettings(configDir: string, settings: ObsSettings) {
// Settings OBS reads from the app (global.ini) and user (user.ini) files;
// written to both so either OBS layout picks them up.
const general = {
General: {
FirstRun: true,
ConfirmOnExit: false,
WarnBeforeStartingStream: false,
WarnBeforeStoppingStream: false,
WarnBeforeStoppingRecord: false,
EnableAutoUpdates: false,
OpenStatsOnStartup: false
},
BasicWindow: {
PreviewEnabled: false,
SysTrayEnabled: true,
SysTrayWhenStarted: true,
SysTrayMinimizeToTray: true
},
Basic: {
Profile: PROFILE,
ProfileDir: PROFILE,
SceneCollection: COLLECTION,
SceneCollectionFile: COLLECTION,
ConfigOnNewProfile: false
}
};
await updateIni(join(configDir, 'user.ini'), general);
await updateIni(join(configDir, 'global.ini'), general);
const profile = join(configDir, 'basic', 'profiles', PROFILE);
await updateIni(join(profile, 'basic.ini'), {
General: { Name: PROFILE },
Output: {
Mode: 'Simple',
Reconnect: true,
RetryDelay: 2,
MaxRetries: 10000,
DelayEnable: false
},
SimpleOutput: {
StreamEncoder: settings.encoder,
VBitrate: settings.bitrateKbps,
ABitrate: 160,
UseAdvanced: false,
StreamAudioEncoder: 'aac',
Preset: 'veryfast',
AMDPreset: 'quality',
RecQuality: 'Stream'
},
Stream1: { IgnoreRecommended: true },
Video: {
BaseCX: settings.width,
BaseCY: settings.height,
OutputCX: settings.width,
OutputCY: settings.height,
FPSType: 1,
FPSInt: settings.fps,
ScaleType: 'bicubic',
ColorFormat: 'NV12',
ColorSpace: '709',
ColorRange: 'Partial'
},
Audio: { SampleRate: 48000, ChannelSetup: 'Stereo' }
});
await writeFile(
join(profile, 'service.json'),
JSON.stringify({
type: 'rtmp_custom',
settings: {
server: settings.ingestUrl,
key: settings.ingestKey,
use_auth: false,
bwtest: false
}
})
);
const websocket = join(configDir, 'plugin_config', 'obs-websocket');
await mkdir(websocket, { recursive: true });
await writeFile(
join(websocket, 'config.json'),
JSON.stringify({
first_load: false,
server_enabled: true,
server_port: settings.websocketPort,
alerts_enabled: false,
auth_required: true,
server_password: settings.websocketPassword
})
);
// A leftover run sentinel makes OBS ask about safe mode after a forced stop; the
// worker only force-stops its own OBS, so clear it.
const sentinel = join(configDir, '.sentinel');
for (const name of await readdir(sentinel).catch(() => [] as string[]))
if (name.startsWith('run_')) await rm(join(sentinel, name), { force: true });
}
/** Arguments for obs64.exe, started with its bin\64bit folder as the working directory. */
export const OBS_ARGS = [
'--portable',
'--multi',
'--disable-updater',
'--disable-missing-files-check',
'--minimize-to-tray'
];
/** Which video encoder the latest OBS log shows starting, if any. */
export function encoderFromLog(log: string): 'amd' | 'x264' | 'other' | null {
let latest: { index: number; kind: 'amd' | 'x264' | 'other' } | null = null;
const patterns: [RegExp, 'amd' | 'x264' | 'other'][] = [
[/\[(texture|fallback)-amf-(h264|h265|av1): /g, 'amd'],
[/\[x264 encoder: /g, 'x264'],
[/\[(obs_nvenc|jim-nvenc|NVENC|qsv|obs-qsv11)[^\]]*: /gi, 'other']
];
for (const [pattern, kind] of patterns)
for (const match of log.matchAll(pattern))
if (!latest || match.index! > latest.index) latest = { index: match.index!, kind };
return latest?.kind ?? null;
}
export async function latestObsLog(configDir: string) {
const dir = join(configDir, 'logs');
const names = (await readdir(dir).catch(() => [] as string[])).filter((name) =>
name.endsWith('.txt')
);
names.sort();
const latest = names.at(-1);
return latest ? readFile(join(dir, latest), 'utf8').catch(() => '') : '';
}

View File

@@ -0,0 +1,382 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { mkdtemp, readFile, rm, writeFile, mkdir, readdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Server } from 'bun';
import { Obs, obsAuthentication } from '../obs';
import { ObsScene, windowSetting, type SceneTarget } from './obs-control';
import { encoderFromLog, parseIni, setIni, stringifyIni, writeObsSettings } from './obs-files';
interface Item {
sceneItemId: number;
sourceName: string;
sceneItemIndex: number;
sceneItemEnabled: boolean;
transform: Record<string, unknown>;
}
/** Just enough obs-websocket 5 for the requests ObsScene makes, with an in-memory scene. */
function fakeObs(password: string, options: { ignore?: string[] } = {}) {
const model = {
scenes: ['Scene'],
program: 'Scene',
inputs: new Map<string, { kind: string; settings: Record<string, unknown> }>([
['Desktop Audio', { kind: 'wasapi_output_capture', settings: {} }]
]),
items: new Map<string, Item[]>([['Scene', []]]),
muted: new Set<string>(),
service: {
streamServiceType: 'rtmp_common',
streamServiceSettings: {} as Record<string, unknown>
},
sources: new Map<string, { width: number; height: number }>(),
requests: [] as string[],
nextId: 1
};
const salt = 'salt';
const challenge = 'challenge';
const handle = (type: string, data: Record<string, unknown>): Record<string, unknown> | null => {
const sceneItems = (scene: string) => model.items.get(scene) ?? [];
switch (type) {
case 'GetSceneList':
return {
scenes: model.scenes.map((sceneName) => ({ sceneName })),
currentProgramSceneName: model.program
};
case 'CreateScene':
model.scenes.push(String(data.sceneName));
model.items.set(String(data.sceneName), []);
return {};
case 'SetCurrentProgramScene':
model.program = String(data.sceneName);
return {};
case 'GetInputList':
return { inputs: [...model.inputs.keys()].map((inputName) => ({ inputName })) };
case 'CreateInput': {
const name = String(data.inputName);
model.inputs.set(name, {
kind: String(data.inputKind),
settings: { ...(data.inputSettings as object) }
});
const items = sceneItems(String(data.sceneName));
items.push({
sceneItemId: model.nextId++,
sourceName: name,
sceneItemIndex: items.length,
sceneItemEnabled: data.sceneItemEnabled !== false,
transform: {}
});
return {};
}
case 'GetInputSettings': {
const input = model.inputs.get(String(data.inputName))!;
return { inputKind: input.kind, inputSettings: input.settings };
}
case 'SetInputSettings':
Object.assign(model.inputs.get(String(data.inputName))!.settings, data.inputSettings);
return {};
case 'GetSceneItemList':
return {
sceneItems: sceneItems(String(data.sceneName)).map((item) => ({
sceneItemId: item.sceneItemId,
sourceName: item.sourceName,
sceneItemIndex: item.sceneItemIndex,
sceneItemEnabled: item.sceneItemEnabled
}))
};
case 'CreateSceneItem': {
const items = sceneItems(String(data.sceneName));
items.push({
sceneItemId: model.nextId++,
sourceName: String(data.sourceName),
sceneItemIndex: items.length,
sceneItemEnabled: true,
transform: {}
});
return {};
}
case 'SetSceneItemIndex': {
const items = sceneItems(String(data.sceneName));
const item = items.find((each) => each.sceneItemId === data.sceneItemId)!;
items.splice(items.indexOf(item), 1);
items.splice(Number(data.sceneItemIndex), 0, item);
items.forEach((each, index) => (each.sceneItemIndex = index));
return {};
}
case 'SetSceneItemEnabled':
sceneItems(String(data.sceneName)).find(
(each) => each.sceneItemId === data.sceneItemId
)!.sceneItemEnabled = data.sceneItemEnabled === true;
return {};
case 'GetSceneItemTransform': {
const item = sceneItems(String(data.sceneName)).find(
(each) => each.sceneItemId === data.sceneItemId
)!;
const size = model.sources.get(item.sourceName) ?? { width: 0, height: 0 };
return {
sceneItemTransform: {
sourceWidth: size.width,
sourceHeight: size.height,
cropBottom: 0,
boundsType: 'OBS_BOUNDS_NONE',
boundsWidth: 0,
boundsHeight: 0,
...item.transform
}
};
}
case 'SetSceneItemTransform':
Object.assign(
sceneItems(String(data.sceneName)).find((each) => each.sceneItemId === data.sceneItemId)!
.transform,
data.sceneItemTransform
);
return {};
case 'GetSpecialInputs':
return { desktop1: 'Desktop Audio', desktop2: null, mic1: null };
case 'SetInputMute':
model.muted.add(String(data.inputName));
return {};
case 'GetStreamServiceSettings':
return model.service;
case 'SetStreamServiceSettings':
model.service = data as typeof model.service;
return {};
default:
return null;
}
};
const server: Server<{ identified: boolean }> = Bun.serve<{ identified: boolean }>({
port: 0,
fetch(request, server) {
return server.upgrade(request, { data: { identified: false } })
? undefined
: new Response('no');
},
websocket: {
open(ws) {
ws.send(
JSON.stringify({ op: 0, d: { rpcVersion: 1, authentication: { salt, challenge } } })
);
},
message(ws, raw) {
const { op, d } = JSON.parse(String(raw));
if (op === 1) {
if (d.authentication !== obsAuthentication(password, salt, challenge)) {
ws.close(4009, 'auth');
return;
}
ws.data.identified = true;
ws.send(JSON.stringify({ op: 2, d: { negotiatedRpcVersion: 1 } }));
return;
}
if (op !== 6 || !ws.data.identified) return;
model.requests.push(d.requestType);
if (options.ignore?.includes(d.requestType)) return;
const result = handle(d.requestType, d.requestData ?? {});
ws.send(
JSON.stringify({
op: 7,
d: {
requestType: d.requestType,
requestId: d.requestId,
requestStatus: result
? { result: true, code: 100 }
: { result: false, code: 204, comment: 'unknown' },
responseData: result ?? undefined
}
})
);
}
}
});
return { server, model, url: `ws://127.0.0.1:${server.port}` };
}
const servers: Server<unknown>[] = [];
afterEach(() => {
for (const server of servers.splice(0)) server.stop(true);
});
const target = (overrides: Partial<SceneTarget> = {}): SceneTarget => ({
canvas: { width: 1920, height: 1080 },
fps: 60,
overlayUrl: 'https://esports.vash.software/broadcast/abc/overlay',
capture: { title: 'Vash osu!tourney (prod)', className: 'WindowsForms10', exe: 'osu!.exe' },
showGameplay: false,
ingest: { server: 'rtmp://127.0.0.1:47103/live', key: 'k' },
...overrides
});
describe('Obs client', () => {
test('reports a wrong websocket password clearly', async () => {
const fake = fakeObs('right');
servers.push(fake.server);
const obs = new Obs(fake.url, 'wrong', 2000);
await expect(obs.connect()).rejects.toThrow('OBS rejected the websocket password');
});
test('connected means authenticated, and a hung request times out', async () => {
const fake = fakeObs('pw', { ignore: ['GetStreamStatus'] });
servers.push(fake.server);
const obs = new Obs(fake.url, 'pw', 300);
expect(obs.connected).toBe(false);
await obs.connect();
expect(obs.connected).toBe(true);
await expect(obs.request('GetStreamStatus')).rejects.toThrow('OBS GetStreamStatus timed out');
await Bun.sleep(400);
obs.close();
expect(obs.connected).toBe(false);
});
});
describe('ObsScene', () => {
test('builds the scene once, keeps the overlay on top, and changes nothing on a second pass', async () => {
const fake = fakeObs('pw');
servers.push(fake.server);
const obs = new Obs(fake.url, 'pw');
await obs.connect();
const scene = new ObsScene(obs, () => {});
await scene.reconcile(target());
const items = fake.model.items.get('Vash')!;
expect(fake.model.program).toBe('Vash');
expect(items.map((item) => item.sourceName)).toEqual(['Gameplay', 'Overlay']);
expect(items[0].sceneItemEnabled).toBe(false);
expect(fake.model.inputs.get('Gameplay')!.settings).toMatchObject({
window: 'Vash osu!tourney (prod):WindowsForms10:osu!.exe',
priority: 1,
capture_audio: true
});
expect(fake.model.inputs.get('Overlay')!.settings).toMatchObject({
width: 1920,
height: 1080,
fps: 60
});
expect(fake.model.muted.has('Desktop Audio')).toBe(true);
expect(fake.model.service.streamServiceSettings).toMatchObject({
server: 'rtmp://127.0.0.1:47103/live',
key: 'k'
});
fake.model.requests.length = 0;
await scene.reconcile(target());
expect(fake.model.requests.filter((type) => /^(Create|Set|Remove)/.test(type))).toEqual([]);
// Someone reorders the scene: a changed target re-checks everything.
scene.reset();
const overlay = items.find((item) => item.sourceName === 'Overlay')!;
items.splice(items.indexOf(overlay), 1);
items.unshift(overlay);
items.forEach((item, index) => (item.sceneItemIndex = index));
await scene.reconcile(target());
expect(fake.model.items.get('Vash')!.at(-1)!.sourceName).toBe('Overlay');
obs.close();
});
test('crops the control panel off the capture and shows it only while spectating', async () => {
const fake = fakeObs('pw');
servers.push(fake.server);
const obs = new Obs(fake.url, 'pw');
await obs.connect();
const scene = new ObsScene(obs, () => {});
await scene.reconcile(target());
// At 125 % display scaling the 1280×920 window captures as 1600×1150.
fake.model.sources.set('Gameplay', { width: 1600, height: 1150 });
await scene.reconcile(target({ showGameplay: true }));
const gameplay = fake.model.items.get('Vash')!.find((item) => item.sourceName === 'Gameplay')!;
expect(gameplay.sceneItemEnabled).toBe(true);
expect(gameplay.transform).toMatchObject({
cropBottom: 250,
boundsType: 'OBS_BOUNDS_SCALE_INNER',
boundsWidth: 1920,
boundsHeight: 1080
});
await scene.reconcile(target({ showGameplay: false }));
expect(gameplay.sceneItemEnabled).toBe(false);
obs.close();
});
});
describe('OBS files', () => {
let dir: string;
afterEach(async () => {
if (dir) await rm(dir, { recursive: true, force: true });
});
test('escapes window settings like libobs', () => {
expect(windowSetting({ title: 'a:b#c', className: 'X', exe: 'osu!.exe' })).toBe(
'a#3Ab#22c:X:osu!.exe'
);
});
test('ini round trip keeps unknown keys', () => {
const ini = parseIni('\uFEFF[General]\r\nName=Old\r\nKeep=1\r\n[Video]\r\nBaseCX=1280\r\n');
setIni(ini, { General: { Name: 'Vash' }, Audio: { SampleRate: 48000 } });
expect(stringifyIni(ini)).toBe(
'[General]\r\nName=Vash\r\nKeep=1\r\n\r\n[Video]\r\nBaseCX=1280\r\n\r\n[Audio]\r\nSampleRate=48000\r\n'
);
});
test('writes profile, websocket and first-run settings, and clears the crash sentinel', async () => {
dir = await mkdtemp(join(tmpdir(), 'vash-obs-'));
await mkdir(join(dir, '.sentinel'), { recursive: true });
await writeFile(join(dir, '.sentinel', 'run_123'), '');
await mkdir(join(dir, 'basic', 'profiles', 'Vash'), { recursive: true });
await writeFile(join(dir, 'basic', 'profiles', 'Vash', 'basic.ini'), '[Hotkeys]\r\nX=1\r\n');
await writeObsSettings(dir, {
websocketPort: 47101,
websocketPassword: 'pw',
width: 1920,
height: 1080,
fps: 60,
bitrateKbps: 6000,
encoder: 'amd',
ingestUrl: 'rtmp://127.0.0.1:47103/live',
ingestKey: 'key'
});
const profile = parseIni(
await readFile(join(dir, 'basic', 'profiles', 'Vash', 'basic.ini'), 'utf8')
);
expect(profile.get('SimpleOutput')?.get('StreamEncoder')).toBe('amd');
expect(profile.get('Video')?.get('FPSInt')).toBe('60');
expect(profile.get('Hotkeys')?.get('X')).toBe('1');
const user = parseIni(await readFile(join(dir, 'user.ini'), 'utf8'));
expect(user.get('General')?.get('FirstRun')).toBe('true');
expect(user.get('Basic')?.get('Profile')).toBe('Vash');
const websocket = JSON.parse(
await readFile(join(dir, 'plugin_config', 'obs-websocket', 'config.json'), 'utf8')
);
expect(websocket).toMatchObject({
server_enabled: true,
server_port: 47101,
auth_required: true,
server_password: 'pw',
first_load: false
});
const service = JSON.parse(
await readFile(join(dir, 'basic', 'profiles', 'Vash', 'service.json'), 'utf8')
);
expect(service).toEqual({
type: 'rtmp_custom',
settings: {
server: 'rtmp://127.0.0.1:47103/live',
key: 'key',
use_auth: false,
bwtest: false
}
});
expect(await readdir(join(dir, '.sentinel'))).toEqual([]);
});
test('reads which encoder OBS started last from its log', () => {
expect(
encoderFromLog("12:00:00.000: [x264 encoder: 'simple_video_stream'] preset: veryfast")
).toBe('x264');
expect(
encoderFromLog(
"[x264 encoder: 'simple_video_stream'] preset: veryfast\n[texture-amf-h264: 'simple_video_stream'] settings:"
)
).toBe('amd');
expect(encoderFromLog('no encoders here')).toBeNull();
});
});

89
streamer/windows/proxy.ts Normal file
View File

@@ -0,0 +1,89 @@
/**
* Dev and staging sit behind Cloudflare Access. The worker can send an
* Access service token with its own requests, but OBS's browser source
* cannot, so it loads the overlay through this local proxy, which adds the
* token to every request and websocket. Production needs none of this.
*/
import type { Server, ServerWebSocket } from 'bun';
interface Tunnel {
target: string;
upstream: WebSocket | null;
queue: (string | Uint8Array)[];
}
/** No Referer, so the CDN's hotlink protection accepts images loaded from 127.0.0.1. */
export function withoutReferrer(html: string) {
const tag = '<meta name="referrer" content="no-referrer">';
return /<head[^>]*>/i.test(html)
? html.replace(/<head[^>]*>/i, (head) => `${head}${tag}`)
: tag + html;
}
export function startAccessProxy(options: {
port: number;
upstream: string;
headers: Record<string, string>;
log: (...parts: unknown[]) => void;
}): Server<Tunnel> {
const upstream = options.upstream.replace(/\/$/, '');
return Bun.serve<Tunnel>({
hostname: '127.0.0.1',
port: options.port,
async fetch(request, server) {
const url = new URL(request.url);
const target = `${upstream}${url.pathname}${url.search}`;
if (request.headers.get('upgrade')?.toLowerCase() === 'websocket') {
const data: Tunnel = { target: target.replace(/^http/, 'ws'), upstream: null, queue: [] };
return server.upgrade(request, { data })
? undefined
: new Response('Upgrade failed', { status: 400 });
}
const headers = new Headers(request.headers);
headers.delete('host');
headers.delete('accept-encoding');
for (const [key, value] of Object.entries(options.headers)) headers.set(key, value);
const response = await fetch(target, {
method: request.method,
headers,
body: ['GET', 'HEAD'].includes(request.method) ? undefined : await request.arrayBuffer(),
redirect: 'manual'
}).catch((err) => new Response(`Proxy: ${(err as Error).message}`, { status: 502 }));
const out = new Headers(response.headers);
out.delete('content-encoding');
out.delete('content-length');
if (out.get('content-type')?.includes('text/html'))
return new Response(withoutReferrer(await response.text()), {
status: response.status,
headers: out
});
return new Response(response.body, { status: response.status, headers: out });
},
websocket: {
open(ws: ServerWebSocket<Tunnel>) {
const socket = new WebSocket(ws.data.target, {
headers: options.headers
} as unknown as string[]);
socket.binaryType = 'arraybuffer';
ws.data.upstream = socket;
socket.onopen = () => {
for (const message of ws.data.queue.splice(0)) socket.send(message);
};
socket.onmessage = (event) =>
ws.send(
typeof event.data === 'string' ? event.data : new Uint8Array(event.data as ArrayBuffer)
);
socket.onclose = () => ws.close();
socket.onerror = () => options.log('Overlay proxy: upstream websocket failed');
},
message(ws, message) {
const socket = ws.data.upstream;
if (socket?.readyState === WebSocket.OPEN) socket.send(message);
else ws.data.queue.push(message);
},
close(ws) {
ws.data.upstream?.close();
}
}
});
}

View File

@@ -0,0 +1,231 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
allocatePorts,
instanceDir,
layout,
listInstances,
portsForSlot,
saveConfig,
validateConfig,
validateServer,
type InstanceConfig
} from './config';
import { fetchPinned, osuCandidates, setSubsystem, subsystemOf, tosuEnv } from './install';
import { acquireLock, lockOwner, releaseLock } from './lock';
import { Supervised, type Child } from './supervise';
let home: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'vash-home-'));
process.env.VASH_BROADCAST_HOME = home;
});
afterEach(async () => {
delete process.env.VASH_BROADCAST_HOME;
await rm(home, { recursive: true, force: true });
});
const config = (name: string, slot: number): InstanceConfig => ({
version: 1,
name,
server: 'https://esports.vash.software',
channel: '0b7f3c2a-1d2e-4f5a-8b9c-0d1e2f3a4b5c',
token: 'vash_token',
access: null,
ports: portsForSlot(slot),
obsPassword: 'a',
ingestKey: 'b',
osuSource: null,
tourneyHeight: 720,
encoder: 'amd',
autostart: true
});
describe('instances', () => {
test('each instance gets its own folder, programs and ports', async () => {
await saveConfig(config('dev', 0));
const others = await listInstances();
const staging = {
...config('staging', 1),
ports: allocatePorts(others.map((each) => each.ports))
};
await saveConfig(staging);
const prod = {
...config('prod', 0),
ports: allocatePorts((await listInstances()).map((each) => each.ports))
};
const all = [config('dev', 0), staging, prod];
const ports = all.flatMap((each) => Object.values(each.ports));
expect(new Set(ports).size).toBe(ports.length);
const dirs = all.map((each) => layout(each.name));
for (const key of ['dir', 'obs', 'osu', 'tosu', 'ffmpeg', 'hls', 'log', 'state'] as const)
expect(new Set(dirs.map((paths) => paths[key])).size).toBe(3);
// Programs live under their instance folder, so stopping one never touches another.
for (const paths of dirs)
for (const key of ['obsExe', 'osuExe', 'tosuExe', 'ffmpeg'] as const)
expect(paths[key].startsWith(paths.dir)).toBe(true);
expect(layout('dev').lock).toBe(layout('prod').lock);
});
test('rejects bad names, servers and configs', () => {
expect(() => instanceDir('../prod')).toThrow();
expect(() => instanceDir('Prod')).toThrow();
expect(validateServer('https://esports.vash.software/broadcast')).toBe(
'https://esports.vash.software'
);
expect(() => validateServer('http://esports.vash.software')).toThrow('https');
expect(validateServer('http://127.0.0.1:3102')).toBe('http://127.0.0.1:3102');
expect(() => validateConfig({ ...config('dev', 0), channel: 'nope' })).toThrow('channel');
expect(() => validateConfig({ ...config('dev', 0), encoder: 'nvenc' })).toThrow('encoder');
});
});
describe('tourney lock', () => {
test('one instance at a time; a dead holder is replaced; only the holder releases it', async () => {
const path = join(home, 'tourney.lock');
expect(await acquireLock(path, 'prod', process.pid)).toEqual({ ok: true });
// Another live process (this test's parent stands in) cannot take it.
const other = await acquireLock(path, 'dev', process.ppid);
expect(other).toMatchObject({ ok: false, owner: { instance: 'prod' } });
await releaseLock(path, process.ppid);
expect(existsSync(path)).toBe(true);
await releaseLock(path, process.pid);
expect(existsSync(path)).toBe(false);
await writeFile(path, JSON.stringify({ instance: 'staging', pid: 2 ** 22 + 12345, at: '' }));
expect(await lockOwner(path)).toBeNull();
expect(await acquireLock(path, 'dev', process.pid)).toEqual({ ok: true });
});
});
describe('Supervised', () => {
function fakeChild(pid: number) {
let exit!: (code: number) => void;
const child: Child & { exit: (code: number) => void; killed: boolean } = {
pid,
exited: new Promise<number | null>((resolve) => (exit = resolve)),
killed: false,
kill() {
child.killed = true;
exit(1);
},
exit: (code) => exit(code)
};
return child;
}
test('restarts with growing delays, forgets failures after a stable run, and stays stopped', async () => {
let now = 0;
const children: ReturnType<typeof fakeChild>[] = [];
const lines: string[] = [];
const supervised = new Supervised(
'OBS',
() => {
const child = fakeChild(children.length + 1);
children.push(child);
return child;
},
{ log: (line) => lines.push(String(line)), now: () => now, stableMs: 60_000 }
);
await supervised.ensure();
expect(supervised.state).toBe('running');
children[0].exit(3);
await Bun.sleep(0);
expect(supervised.state).toBe('backoff');
expect(lines.at(-1)).toBe('OBS exited with 3; restarting in 2s');
now += 1000;
await supervised.ensure();
expect(children).toHaveLength(1);
now += 1000;
await supervised.ensure();
expect(children).toHaveLength(2);
children[1].exit(1);
await Bun.sleep(0);
expect(lines.at(-1)).toContain('restarting in 4s');
now += 4000;
await supervised.ensure();
now += 120_000;
children[2].exit(1);
await Bun.sleep(0);
expect(lines.at(-1)).toContain('restarting in 2s');
now += 2000;
await supervised.ensure();
await supervised.stop();
expect(children[3].killed).toBe(true);
expect(supervised.state).toBe('stopped');
now += 60_000;
expect(supervised.restarts).toBe(3);
});
});
describe('install', () => {
test('switches a PE executable between console and windowless', () => {
// MZ header pointing at a PE header with a PE32+ optional header.
const exe = new Uint8Array(512);
const view = new DataView(exe.buffer);
view.setUint16(0, 0x5a4d, true);
view.setUint32(0x3c, 0x80, true);
view.setUint32(0x80, 0x00004550, true);
view.setUint16(0x80 + 24, 0x20b, true);
view.setUint32(0x80 + 24 + 64, 0x1234, true);
view.setUint16(0x80 + 24 + 68, 3, true);
setSubsystem(exe, 2);
expect(subsystemOf(exe)).toBe(2);
expect(view.getUint32(0x80 + 24 + 64, true)).toBe(0);
expect(() => setSubsystem(new Uint8Array(512), 2)).toThrow('Not an MZ executable');
});
test('downloads only what matches the pinned checksum', async () => {
const body = new TextEncoder().encode('pinned release bytes');
const server = Bun.serve({ port: 0, fetch: () => new Response(body) });
const downloads = join(home, 'downloads');
const sha256 = createHash('sha256').update(body).digest('hex');
const url = `http://127.0.0.1:${server.port}/tool.zip`;
try {
const path = await fetchPinned(
{ name: 'tool', version: '1', url, sha256, bytes: body.length },
downloads,
() => {}
);
expect(path).toBe(join(downloads, 'tool.zip'));
await expect(
fetchPinned(
{
name: 'bad',
version: '1',
url: url.replace('tool', 'bad'),
sha256: '0'.repeat(64),
bytes: 0
},
downloads,
() => {}
)
).rejects.toThrow('did not match its pinned SHA-256');
expect(await readdir(downloads)).toEqual(['tool.zip']);
} finally {
server.stop(true);
}
});
test('tosu gets its port, no auto-update and no browser', () => {
const env = tosuEnv(47102);
expect(env).toContain('SERVER_PORT=47102');
expect(env).toContain('SERVER_IP=127.0.0.1');
expect(env).toContain('ENABLE_AUTOUPDATE=false');
expect(env).toContain('OPEN_DASHBOARD_ON_STARTUP=false');
});
test('looks for osu! where stable installs itself', () => {
expect(osuCandidates({ LOCALAPPDATA: 'C:\\Users\\stan\\AppData\\Local' })[0]).toBe(
join('C:\\Users\\stan\\AppData\\Local', 'osu!', 'osu!.exe')
);
});
});

View File

@@ -0,0 +1,123 @@
/**
* Keeps one child program running: restarts it when it exits, waiting longer
* after each quick failure (2 s, 4 s, … up to a minute) and forgetting past
* failures once it has stayed up for a while.
*/
export interface Child {
readonly pid: number;
readonly exited: Promise<number | null>;
kill(): void;
}
export type SupervisedState = 'stopped' | 'starting' | 'running' | 'backoff';
export interface SupervisorOptions {
log: (...parts: unknown[]) => void;
now?: () => number;
/** A run at least this long resets the backoff. */
stableMs?: number;
baseDelayMs?: number;
maxDelayMs?: number;
}
export class Supervised {
state: SupervisedState = 'stopped';
child: Child | null = null;
restarts = 0;
failures = 0;
lastExit: string | null = null;
startedAt = 0;
private retryAt = 0;
private wanted = false;
private now: () => number;
constructor(
readonly name: string,
private launch: () => Child | Promise<Child>,
private options: SupervisorOptions
) {
this.now = options.now ?? Date.now;
}
get running() {
return this.state === 'running';
}
/** Starts the program if it should run and is not; call on every tick. */
async ensure() {
this.wanted = true;
if (this.state === 'running' || this.state === 'starting') return;
if (this.state === 'backoff' && this.now() < this.retryAt) return;
this.state = 'starting';
let child: Child;
try {
child = await this.launch();
} catch (err) {
this.onExit(`could not start: ${(err as Error).message}`);
return;
}
if (!this.wanted) {
child.kill();
this.state = 'stopped';
return;
}
this.child = child;
this.startedAt = this.now();
this.state = 'running';
void child.exited.then((code) => {
if (this.child !== child) return;
this.child = null;
this.onExit(`exited with ${code}`);
});
}
private onExit(reason: string) {
this.lastExit = reason;
if (!this.wanted) {
this.state = 'stopped';
return;
}
const ranFor = this.startedAt ? this.now() - this.startedAt : 0;
if (ranFor >= (this.options.stableMs ?? 5 * 60_000)) this.failures = 0;
const delay = Math.min(
this.options.maxDelayMs ?? 60_000,
(this.options.baseDelayMs ?? 2000) * 2 ** this.failures
);
this.failures++;
this.restarts++;
this.startedAt = 0;
this.retryAt = this.now() + delay;
this.state = 'backoff';
this.options.log(`${this.name} ${reason}; restarting in ${Math.round(delay / 1000)}s`);
}
/** Stops the program and keeps it stopped until the next ensure(). */
async stop(graceful?: (child: Child) => Promise<void>) {
this.wanted = false;
const child = this.child;
this.child = null;
this.state = 'stopped';
if (!child) return;
if (graceful) {
await graceful(child).catch(() => {});
const done = await Promise.race([
child.exited.then(() => true),
Bun.sleep(10_000).then(() => false)
]);
if (done) return;
}
child.kill();
await Promise.race([child.exited, Bun.sleep(5000)]);
}
status() {
return {
state: this.state,
pid: this.child?.pid ?? null,
restarts: this.restarts,
lastExit: this.lastExit,
upSec: this.state === 'running' ? Math.round((this.now() - this.startedAt) / 1000) : 0
};
}
}

132
streamer/windows/tasks.ts Normal file
View File

@@ -0,0 +1,132 @@
/**
* The scheduled task that starts an instance inside the signed-in Windows
* desktop. Programs started straight from an SSH session run in a session
* with no desktop, where OBS, osu! and window capture cannot work; a task
* with LogonType Interactive runs in the user's own desktop session instead,
* and keeps running when the SSH connection closes.
*/
export const taskName = (instance: string) => `Vash Broadcast (${instance})`;
/** Runs a PowerShell script with values passed as environment variables (no quoting problems). */
export async function powershell(script: string, env: Record<string, string> = {}) {
if (process.platform !== 'win32') throw new Error('Scheduled tasks exist only on Windows');
// -EncodedCommand (UTF-16LE, base64) sidesteps Windows command-line quoting entirely.
const encoded = Buffer.from(
`$ProgressPreference = 'SilentlyContinue'\n${script}`,
'utf16le'
).toString('base64');
const child = Bun.spawn(
[
'powershell.exe',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-EncodedCommand',
encoded
],
{ stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ...env }, windowsHide: true }
);
const [stdout, stderr, code] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited
]);
if (code !== 0)
throw new Error(
stderr.trim().split(/\r?\n/).slice(0, 3).join(' ') || `PowerShell exited with ${code}`
);
return stdout.trim();
}
export async function registerTask(options: {
instance: string;
exe: string;
cwd: string;
autostart: boolean;
}) {
await powershell(
`$ErrorActionPreference = 'Stop'
$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
$action = New-ScheduledTaskAction -Execute $env:VASH_EXE -Argument $env:VASH_ARGS -WorkingDirectory $env:VASH_CWD
$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
# Priority 4 is normal; the default (7) would slow OBS and osu! down.
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew -StartWhenAvailable -Priority 4
$task = @{ Action = $action; Principal = $principal; Settings = $settings; Description = 'Vash broadcast worker. Manage it with vash-broadcast.exe.' }
if ($env:VASH_AUTOSTART -eq '1') { $task.Trigger = New-ScheduledTaskTrigger -AtLogOn -User $user }
$definition = New-ScheduledTask @task
Register-ScheduledTask -TaskName $env:VASH_TASK -InputObject $definition -Force | Out-Null`,
{
VASH_TASK: taskName(options.instance),
VASH_EXE: options.exe,
VASH_ARGS: `task --instance ${options.instance}`,
VASH_CWD: options.cwd,
VASH_AUTOSTART: options.autostart ? '1' : '0'
}
);
}
export async function runTask(instance: string) {
await powershell(`Start-ScheduledTask -TaskName $env:VASH_TASK`, {
VASH_TASK: taskName(instance)
});
}
export async function endTask(instance: string) {
await powershell(`Stop-ScheduledTask -TaskName $env:VASH_TASK -ErrorAction SilentlyContinue`, {
VASH_TASK: taskName(instance)
}).catch(() => {});
}
export async function unregisterTask(instance: string) {
await powershell(
`Unregister-ScheduledTask -TaskName $env:VASH_TASK -Confirm:$false -ErrorAction SilentlyContinue`,
{ VASH_TASK: taskName(instance) }
).catch(() => {});
}
export interface TaskInfo {
exists: boolean;
state: string | null;
lastRunTime: string | null;
lastTaskResult: number | null;
autostart: boolean;
}
export async function taskInfo(instance: string): Promise<TaskInfo> {
const output = await powershell(
`$task = Get-ScheduledTask -TaskName $env:VASH_TASK -ErrorAction SilentlyContinue
if ($null -eq $task) { '{"exists":false}'; exit }
$info = $task | Get-ScheduledTaskInfo
@{ exists = $true; state = [string]$task.State; lastRunTime = if ($info.LastRunTime) { $info.LastRunTime.ToString('o') } else { $null }; lastTaskResult = [long]$info.LastTaskResult; autostart = @($task.Triggers).Count -gt 0 } | ConvertTo-Json -Compress`,
{ VASH_TASK: taskName(instance) }
);
const info = JSON.parse(output) as Partial<TaskInfo>;
return {
exists: !!info.exists,
state: info.state ?? null,
lastRunTime: info.lastRunTime ?? null,
lastTaskResult: info.lastTaskResult ?? null,
autostart: !!info.autostart
};
}
/** Who is signed in at the PC's own screen, if anyone. */
export async function consoleUser() {
const output = await powershell(`(Get-CimInstance Win32_ComputerSystem).UserName`).catch(
() => ''
);
return output || null;
}
export async function currentUser() {
return powershell(`[System.Security.Principal.WindowsIdentity]::GetCurrent().Name`);
}
export async function gpuNames() {
const output = await powershell(
`(Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name) -join "\`n"`
).catch(() => '');
return output.split(/\r?\n/).filter(Boolean);
}

View File

@@ -0,0 +1,250 @@
import { afterEach, beforeEach, describe, expect, setSystemTime, test } from 'bun:test';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { tournamentConfig } from '../tourney';
import type { Follow } from '../link';
import type { OcrLine } from './desktop';
import { FakeDesktop, fakeTosu, tourneyFrame, windowInfo } from './fakes';
import {
findRoomLine,
parseRoomName,
pickManagerWindow,
savedLogin,
teamsMatch,
TourneyController
} from './tourney';
const line = (text: string, y: number): OcrLine => ({ text, x: 10, y, width: 600, height: 30 });
describe('room names and matching', () => {
test('parses the acronym and both team names', () => {
expect(parseRoomName('VASH: (Red Team) vs (Blue: Team)')).toEqual({
acronym: 'VASH',
teams: ['Red Team', 'Blue: Team']
});
expect(parseRoomName('not a tourney room')).toBeNull();
expect(parseRoomName(null)).toBeNull();
});
test('finds the room by its lobby id, even with OCR look-alike letters', () => {
const lines = [
line('118000001 : @VASH: (Alpha) vs (Beta) () 0/16', 0),
line('11800OO42 : @VASH: (Alpha) vs (Beta) () 2/16', 40)
];
const match = findRoomLine(lines, 118000042, 'VASH: (Alpha) vs (Beta)');
expect(match).toMatchObject({ found: true, by: 'lobby id', line: { y: 40 } });
});
test('falls back to the room name only when exactly one room has it', () => {
const one = [line('VASH: (Other) vs (Team)', 0), line('VASH: (Alpha) vs (Beta)', 40)];
expect(findRoomLine(one, 5, 'VASH: (Alpha) vs (Beta)')).toMatchObject({
found: true,
by: 'room name',
line: { y: 40 }
});
const two = [line('VASH: (Alpha) vs (Beta)', 0), line('VASH: (Alpha) vs (Beta)', 40)];
const ambiguous = findRoomLine(two, 5, 'VASH: (Alpha) vs (Beta)');
expect(ambiguous.found).toBe(false);
expect(ambiguous.found || ambiguous.reason).toContain('2 rooms are named');
expect(
findRoomLine([line('Make a multiplayer room with format:', 0)], 5, 'VASH: (A) vs (B)')
).toMatchObject({
found: false,
reason: 'the room is not listed yet'
});
});
test('confirms the room from the team names tosu reads', () => {
expect(teamsMatch({ left: 'Alpha', right: 'Beta' }, 'VASH: (Alpha) vs (Beta)')).toBe(true);
expect(teamsMatch({ left: 'beta', right: 'ALPHA' }, 'VASH: (Alpha) vs (Beta)')).toBe(true);
expect(teamsMatch({ left: 'Alpha', right: 'Gamma' }, 'VASH: (Alpha) vs (Beta)')).toBe(false);
expect(teamsMatch({ left: '', right: '' }, 'VASH: ( ) vs ( )')).toBe(false);
expect(teamsMatch(null, 'VASH: (Alpha) vs (Beta)')).toBe(false);
});
test('picks the manager window (control panel below 16:9), not a spectator window', () => {
const spectator = windowInfo({ hwnd: 1, clientWidth: 640, clientHeight: 360 });
const manager = windowInfo({ hwnd: 2, clientWidth: 1280, clientHeight: 920 });
const hidden = windowInfo({ hwnd: 3, clientWidth: 1920, clientHeight: 1300, visible: false });
expect(pickManagerWindow([spectator, hidden, manager])?.hwnd).toBe(2);
expect(pickManagerWindow([spectator])).toBeNull();
});
test('reads a remembered login from osu!.<user>.cfg', () => {
expect(savedLogin('Username = spectator\r\nPassword = AQAAANCM\r\nSavePassword = 1')).toEqual({
username: 'spectator',
saved: true
});
expect(savedLogin('Username = spectator\nPassword = \n')).toEqual({
username: 'spectator',
saved: false
});
expect(savedLogin('')).toEqual({ username: null, saved: false });
});
test('tournament.cfg keeps other keys and sets ours', () => {
expect(
tournamentConfig('privateserver = 0\r\nTeamSize = 4\r\nacronym = OWC\r\n', {
teamSize: 2,
height: 720,
acronym: 'VASH'
})
).toBe('privateserver = 0\nTeamSize = 2\nacronym = VASH\nHeight = 720\n');
});
});
describe('TourneyController', () => {
let dir: string;
let desktop: FakeDesktop;
let tosu: ReturnType<typeof fakeTosu>;
let launches: number;
let tourney: TourneyController;
let now: number;
const advance = (ms: number) => {
now += ms;
setSystemTime(new Date(now));
};
const follow = (overrides: Partial<Follow> = {}): Follow => ({
scene: 'gameplay',
matchId: 'm1',
lobbyId: 118000042,
roomName: 'VASH: (Alpha) vs (Beta)',
teamSize: 2,
title: 'Alpha vs Beta',
...overrides
});
beforeEach(async () => {
now = Date.parse('2026-09-29T12:00:00Z');
setSystemTime(new Date(now));
dir = await mkdtemp(join(tmpdir(), 'vash-tourney-'));
await writeFile(join(dir, 'osu!.stan.cfg'), 'Username = spectator\nPassword = secret\n');
desktop = new FakeDesktop();
tosu = fakeTosu();
launches = 0;
tourney = new TourneyController({
osuDir: dir,
snapshots: join(dir, 'snapshots'),
height: 720,
windowTitle: 'Vash osu!tourney (test)',
desktop,
tosu,
log: () => {},
launch: () => {
launches++;
desktop.running.push({ pid: 10 + launches, path: join(dir, 'osu!.exe') });
}
});
});
afterEach(async () => {
setSystemTime();
await rm(dir, { recursive: true, force: true });
});
test('launches with the match team size, selects the room by lobby id and confirms it', async () => {
tourney.follow(follow());
await tourney.tick(desktop.sessionInfo);
expect(launches).toBe(1);
expect(tourney.state).toBe('starting');
expect(await readFile(join(dir, 'tournament.cfg'), 'utf8')).toBe(
'TeamSize = 2\nacronym = VASH\nHeight = 720\n'
);
desktop.windowList = [windowInfo({ pid: 11 })];
await tourney.tick(desktop.sessionInfo);
expect(desktop.called('title')[0].args).toEqual([100, 'Vash osu!tourney (test)']);
expect(desktop.called('click')).toHaveLength(0);
advance(9000);
desktop.ocrLines = [
line('118000001 : @VASH: (Other) vs (Team) () 0/16', 20),
line('118000042 : @VASH: (Alpha) vs (Beta) () 2/16', 60)
];
await tourney.tick(desktop.sessionInfo);
// Panel at client (8, 31 + 720), captured at 2.5×: the second line's start.
expect(desktop.called('click')[0].args).toEqual([52, 781]);
expect(tourney.state).toBe('selecting');
expect(tourney.verified).toBe(false);
tosu.frame = tourneyFrame('Alpha', 'Beta');
advance(2000);
await tourney.tick(desktop.sessionInfo);
expect(tourney.state).toBe('spectating');
expect(tourney.verified).toBe(true);
});
test('restarts osu!tourney for a different team size, and not for an idle channel', async () => {
tourney.follow(follow());
await tourney.tick(desktop.sessionInfo);
desktop.windowList = [windowInfo({ pid: 11 })];
tourney.follow(
follow({ matchId: null, lobbyId: null, roomName: null, teamSize: null, title: null })
);
await tourney.tick(desktop.sessionInfo);
expect(launches).toBe(1);
expect(tourney.state).toBe('idle');
tourney.follow(follow({ lobbyId: 5, teamSize: 1, roomName: 'VASH: (C) vs (D)' }));
await tourney.tick(desktop.sessionInfo);
expect(desktop.called('close')).toHaveLength(1);
expect(launches).toBe(2);
expect(await readFile(join(dir, 'tournament.cfg'), 'utf8')).toContain('TeamSize = 1');
});
test('does not click while Windows is locked', async () => {
tourney.follow(follow());
await tourney.tick(desktop.sessionInfo);
desktop.windowList = [windowInfo({ pid: 11 })];
await tourney.tick(desktop.sessionInfo);
advance(9000);
desktop.sessionInfo = { ...desktop.sessionInfo, inputDesktop: 'Winlogon' };
await tourney.tick(desktop.sessionInfo);
expect(tourney.state).toBe('desktop-locked');
expect(desktop.called('click')).toHaveLength(0);
});
test('selects again when tosu shows a different room after the click', async () => {
tourney.follow(follow());
await tourney.tick(desktop.sessionInfo);
desktop.windowList = [windowInfo({ pid: 11 })];
await tourney.tick(desktop.sessionInfo);
advance(9000);
desktop.ocrLines = [line('118000042 : @VASH: (Alpha) vs (Beta) () 2/16', 60)];
await tourney.tick(desktop.sessionInfo);
expect(desktop.called('click')).toHaveLength(1);
tosu.frame = tourneyFrame('Other', 'Team');
advance(5000);
await tourney.tick(desktop.sessionInfo);
expect(tourney.state).toBe('selecting');
advance(13_000);
await tourney.tick(desktop.sessionInfo);
expect(tourney.verified).toBe(false);
advance(11_000);
await tourney.tick(desktop.sessionInfo);
expect(desktop.called('click')).toHaveLength(2);
});
test('waits for the room when it is not listed, and never clicks another room', async () => {
tourney.follow(follow());
await tourney.tick(desktop.sessionInfo);
desktop.windowList = [windowInfo({ pid: 11 })];
await tourney.tick(desktop.sessionInfo);
advance(9000);
desktop.ocrLines = [line('118000001 : @VASH: (Other) vs (Team) () 0/16', 20)];
await tourney.tick(desktop.sessionInfo);
expect(tourney.state).toBe('waiting-for-lobby');
expect(tourney.detail).toContain('1 room listed');
expect(desktop.called('click')).toHaveLength(0);
});
test('asks for the one-time sign-in instead of launching without a saved login', async () => {
await writeFile(join(dir, 'osu!.stan.cfg'), 'Username = \n');
tourney.follow(follow());
await tourney.tick(desktop.sessionInfo);
expect(tourney.state).toBe('login-required');
expect(launches).toBe(0);
});
});

437
streamer/windows/tourney.ts Normal file
View File

@@ -0,0 +1,437 @@
/**
* osu!tourney on Windows: keeps the instance's own tourney client running
* with the right tournament.cfg, finds its window, and spectates the lobby
* Vash asks for, without anyone clicking.
*
* Selecting a room: osu!tourney lists rooms named "<acronym>: (A) vs (B)" in
* its control panel as "<match id> : <room name> …". There is no command or
* IPC for choosing one, so the worker reads the panel with Windows' own OCR,
* clicks the line with the lobby's match id (or, failing that, the only line
* with both team names), and then checks tosu: osu!tourney's current team
* names must be the ones in the requested room before it counts as selected.
*/
import { existsSync } from 'node:fs';
import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { plain, tournamentConfig } from '../tourney';
import type { Follow } from '../link';
import type { TosuFeed } from '../tosu';
import type { Desktop, OcrLine, SessionInfo, WindowInfo } from './desktop';
// ── Pure helpers (tested) ────────────────────────────────────────────────
/** "VASH: (Red) vs (Blue)" → acronym and team names. */
export function parseRoomName(roomName: string | null) {
const match = roomName ? /^(.+?): \((.*)\) vs \((.*)\)\s*$/.exec(roomName) : null;
if (!match || !match[1].trim()) return null;
return { acronym: match[1].trim(), teams: [match[2], match[3]] as [string, string] };
}
/** The streamable area is 16:9 across the window's width; the control panel sits below it. */
export function gameplayHeight(clientWidth: number) {
return Math.round((clientWidth * 9) / 16);
}
/** The manager window is taller than 16:9 by its control panel; spectator windows are not. */
export function pickManagerWindow(windows: WindowInfo[]): WindowInfo | null {
const candidates = windows.filter(
(window) =>
window.visible &&
window.clientWidth >= 400 &&
window.clientHeight > gameplayHeight(window.clientWidth) + 60
);
candidates.sort((a, b) => b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight);
return candidates[0] ?? null;
}
/** The biggest visible window of the osu! installation (for sign-in, when there is no panel). */
export function pickMainWindow(windows: WindowInfo[]): WindowInfo | null {
const visible = windows.filter((window) => window.visible && window.clientWidth > 0);
visible.sort((a, b) => b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight);
return visible[0] ?? null;
}
/** OCR misreads digits as look-alike letters; read a leading "match id :" generously. */
function leadingNumber(text: string) {
const match = /^\s*([0-9OoIl|SB]{5,})\s*[:;.]/.exec(text);
if (!match) return null;
return match[1]
.replace(/[Oo]/g, '0')
.replace(/[Il|]/g, '1')
.replace(/S/g, '5')
.replace(/B/g, '8');
}
export type RoomMatch =
| { found: true; line: OcrLine; by: 'lobby id' | 'room name' }
| { found: false; reason: string; rooms: number };
/**
* Finds the requested room in the control panel's OCR lines. The lobby's
* match id is the reliable key; the room name is used only when exactly one
* listed room has both team names, never "the first room".
*/
export function findRoomLine(
lines: OcrLine[],
lobbyId: number | null,
roomName: string | null
): RoomMatch {
const rooms = lines.filter((line) => /\bvs\b/i.test(line.text) || leadingNumber(line.text));
if (lobbyId != null) {
const id = String(lobbyId);
const byId = lines.find(
(line) => leadingNumber(line.text) === id || line.text.replace(/\s+/g, '').includes(`${id}:`)
);
if (byId) return { found: true, line: byId, by: 'lobby id' };
}
const room = parseRoomName(roomName);
if (!room) return { found: false, reason: 'no room name to look for', rooms: rooms.length };
const wanted = plain(`${room.acronym}${room.teams[0]}vs${room.teams[1]}`);
const byName = lines.filter((line) => plain(line.text).includes(wanted));
if (byName.length === 1) return { found: true, line: byName[0], by: 'room name' };
if (byName.length > 1)
return {
found: false,
reason: `${byName.length} rooms are named ${roomName}; waiting for the lobby id to be readable`,
rooms: rooms.length
};
return { found: false, reason: 'the room is not listed yet', rooms: rooms.length };
}
/** Whether osu!tourney (via tosu) is spectating a room with these team names. */
export function teamsMatch(
actual: { left: string; right: string } | null | undefined,
roomName: string | null
) {
const room = parseRoomName(roomName);
if (!room || !actual) return false;
const [a, b] = room.teams.map(plain);
const left = plain(actual.left);
const right = plain(actual.right);
if (!left && !right) return false;
return (left === a && right === b) || (left === b && right === a);
}
/** osu! stable remembers a login in osu!.<Windows user>.cfg. */
export function savedLogin(cfg: string) {
const value = (key: string) =>
new RegExp(`^\\s*${key}\\s*=\\s*(.*?)\\s*$`, 'mi').exec(cfg)?.[1] ?? '';
const username = value('Username');
return { username: username || null, saved: !!username && !!value('Password') };
}
export async function readSavedLogin(osuDir: string) {
const files = (await readdir(osuDir).catch(() => [] as string[])).filter((name) =>
/^osu!\..+\.cfg$/i.test(name)
);
for (const name of files) {
const login = savedLogin(await readFile(join(osuDir, name), 'utf8').catch(() => ''));
if (login.saved) return login;
}
return { username: null, saved: false };
}
// ── The controller ──────────────────────────────────────────────────────
export type TourneyState =
| 'stopped'
| 'login-required'
| 'starting'
| 'desktop-locked'
| 'idle'
| 'waiting-for-lobby'
| 'selecting'
| 'spectating'
| 'error';
export interface TourneyOptions {
osuDir: string;
snapshots: string;
height: number;
/** Shown in the window title so OBS captures exactly this window. */
windowTitle: string;
desktop: Desktop;
tosu: TosuFeed;
log: (...parts: unknown[]) => void;
/** Starts osu!.exe; defaults to Bun.spawn. */
launch?: (exe: string, cwd: string) => void;
}
export class TourneyController {
state: TourneyState = 'stopped';
detail: string | null = null;
window: WindowInfo | null = null;
/** The room was clicked and tosu confirmed it (or tosu is down and could not). */
verified = false;
private want: Follow | null = null;
private launchedWith: string | null = null;
private launchedAt = 0;
private windowSeenAt = 0;
private selected: { lobbyId: number; at: number } | null = null;
private mismatchSince = 0;
private nextAttempt = 0;
private failures = 0;
private busy = false;
/** Kept between matches, so an idle channel does not restart osu!tourney. */
private teamSize = 1;
private acronym = 'VASH';
constructor(private options: TourneyOptions) {}
private get log() {
return this.options.log;
}
/** What Vash wants shown; a new lobby or team size takes effect on the next tick. */
follow(follow: Follow) {
const lobbyChanged = follow.lobbyId !== this.want?.lobbyId;
this.want = follow;
if (follow.teamSize) this.teamSize = Math.min(8, Math.max(1, follow.teamSize));
const room = parseRoomName(follow.roomName);
if (room) this.acronym = room.acronym;
if (lobbyChanged) {
this.selected = null;
this.verified = false;
this.failures = 0;
this.nextAttempt = 0;
}
}
/** The tournament.cfg the wanted match needs. */
private desiredConfig() {
return { teamSize: this.teamSize, acronym: this.acronym, height: this.options.height };
}
private set(state: TourneyState, detail: string | null = null) {
if (state !== this.state || detail !== this.detail) {
if (state !== this.state) this.log(`osu!tourney: ${state}${detail ? ` (${detail})` : ''}`);
this.state = state;
this.detail = detail;
}
}
/** Advances launch, window discovery and room selection. Call every couple of seconds. */
async tick(session: SessionInfo | null) {
if (this.busy) return;
this.busy = true;
try {
await this.step(session);
} catch (err) {
this.set('error', (err as Error).message);
} finally {
this.busy = false;
}
}
private async step(session: SessionInfo | null) {
const { desktop, osuDir } = this.options;
const login = await readSavedLogin(osuDir);
if (!login.saved) {
this.set('login-required', 'Run vash-broadcast login once to sign in the spectator account');
return;
}
const desired = this.desiredConfig();
const key = JSON.stringify(desired);
const processes = await desktop.processes(osuDir);
if (!processes.length || this.launchedWith !== key) {
if (processes.length) {
this.log(
`Restarting osu!tourney for ${desired.teamSize}v${desired.teamSize} (${desired.acronym})`
);
await this.stopProcesses();
}
await this.writeConfig(desired);
this.launch();
this.launchedWith = key;
this.launchedAt = Date.now();
this.window = null;
this.windowSeenAt = 0;
this.selected = null;
this.verified = false;
this.set('starting', 'launching osu!tourney');
return;
}
const windows = await desktop.windows(osuDir);
const manager = pickManagerWindow(windows);
if (!manager) {
// A minimized manager has no client area to capture; bring it back. Only the window
// already known as the manager (it carries our title), never a spectator window.
const minimized = windows.find(
(window) => window.minimized && window.title === this.options.windowTitle
);
if (minimized) {
this.log('osu!tourney was minimized; restoring it');
await desktop.focus(minimized.hwnd);
return;
}
this.window = null;
const waited = Date.now() - this.launchedAt;
// A first start may update osu! itself for a few minutes.
if (waited > 5 * 60_000) {
this.log('osu!tourney window did not appear; restarting it');
await this.stopProcesses();
this.launchedWith = null;
this.set('error', 'osu!tourney did not open its window in 5 minutes');
} else this.set('starting', 'waiting for the osu!tourney window');
return;
}
if (!this.windowSeenAt) this.windowSeenAt = Date.now();
this.window = manager;
if (manager.title !== this.options.windowTitle)
await desktop.setTitle(manager.hwnd, this.options.windowTitle);
if (session && (manager.x < -8 || manager.y < -8 || manager.x + 100 > session.screenWidth)) {
await desktop.move(manager.hwnd, 0, 0);
this.window = (await desktop.window(manager.hwnd)) ?? manager;
}
const want = this.want;
if (!want?.lobbyId || !want.roomName) {
this.verified = false;
this.set('idle', want?.matchId ? 'the match has no osu! lobby to spectate' : null);
return;
}
const frame = this.options.tosu.fresh();
if (teamsMatch(frame?.teams, want.roomName)) {
this.mismatchSince = 0;
if (!this.verified)
this.log(`Spectating ${want.roomName} (mp/${want.lobbyId}), confirmed by tosu`);
this.selected = { lobbyId: want.lobbyId, at: this.selected?.at ?? Date.now() };
this.verified = true;
this.failures = 0;
this.set('spectating', null);
return;
}
if (this.selected?.lobbyId === want.lobbyId) {
if (!frame) {
// Nothing can confirm the click while tosu is down; say so rather than claim success.
this.verified = true;
this.set('spectating', 'room selected; tosu is not answering, so it is unconfirmed');
return;
}
this.mismatchSince ||= Date.now();
if (Date.now() - this.mismatchSince < 12_000) {
this.set('selecting', `waiting for osu!tourney to join ${want.roomName}`);
return;
}
this.log(
`osu!tourney shows "${frame.teams.left}" vs "${frame.teams.right}", not ${want.roomName}; selecting again`
);
this.selected = null;
this.verified = false;
this.failures++;
}
this.verified = false;
// osu!tourney needs a moment after its window appears before the room list fills.
if (Date.now() - this.windowSeenAt < 8000 || Date.now() < this.nextAttempt) return;
await this.select(want, manager, session);
}
private async select(want: Follow, manager: WindowInfo, session: SessionInfo | null) {
const { desktop, snapshots } = this.options;
if (session && session.inputDesktop !== 'Default') {
this.set(
'desktop-locked',
'Windows is locked; unlock it (or disable the lock screen) so the room can be selected'
);
this.nextAttempt = Date.now() + 5000;
return;
}
this.nextAttempt = Date.now() + Math.min(30_000, 5000 * 2 ** Math.min(this.failures, 3));
if (!(await desktop.focus(manager.hwnd))) {
this.set('selecting', 'could not bring osu!tourney to the front');
this.failures++;
return;
}
const window = (await desktop.window(manager.hwnd)) ?? manager;
const top = gameplayHeight(window.clientWidth);
const region = {
x: window.clientX,
y: window.clientY + top,
width: Math.round(window.clientWidth * 0.7),
height: window.clientHeight - top
};
const limit = await desktop
.ocrReady()
.then((ready) => ready.maxDimension)
.catch(() => 2600);
const scale = Math.max(1, Math.min(2.5, limit / region.width));
await mkdir(snapshots, { recursive: true });
const image = join(snapshots, 'tourney-panel.png');
await desktop.capture({ ...region, path: image, scale, invert: true });
const lines = await desktop.ocr(image);
await writeFile(
join(snapshots, 'tourney-panel.json'),
JSON.stringify({ at: new Date().toISOString(), scale, region, lines }, null, '\t')
);
const match = findRoomLine(lines, want.lobbyId, want.roomName);
if (!match.found) {
this.set(
'waiting-for-lobby',
`${match.reason} (${match.rooms} room${match.rooms === 1 ? '' : 's'} listed)`
);
return;
}
const x = Math.round(region.x + (match.line.x + Math.min(match.line.width, 200) / 2) / scale);
const y = Math.round(region.y + (match.line.y + match.line.height / 2) / scale);
this.log(`Clicking ${want.roomName} (mp/${want.lobbyId}), found by ${match.by} at ${x},${y}`);
if (!(await desktop.click(x, y))) {
this.set('selecting', 'Windows refused the click');
this.failures++;
return;
}
this.selected = { lobbyId: want.lobbyId!, at: Date.now() };
this.mismatchSince = Date.now();
this.set('selecting', `joining ${want.roomName}`);
}
private async writeConfig(desired: { teamSize: number; acronym: string; height: number }) {
const path = join(this.options.osuDir, 'tournament.cfg');
const existing = await readFile(path, 'utf8').catch(() => '');
await writeFile(path, tournamentConfig(existing, desired));
}
private launch() {
const exe = join(this.options.osuDir, 'osu!.exe');
if (this.options.launch) return this.options.launch(exe, this.options.osuDir);
Bun.spawn([exe], { cwd: this.options.osuDir, stdout: 'ignore', stderr: 'ignore' });
}
/** Closes every osu! process of this installation (manager and spectator clients). */
async stopProcesses() {
const { desktop, osuDir } = this.options;
const processes = await desktop.processes(osuDir);
for (const process of processes) await desktop.close(process.pid).catch(() => 0);
for (let waited = 0; waited < 8000; waited += 500) {
if (!(await desktop.processes(osuDir)).length) return;
await Bun.sleep(500);
}
await desktop.kill((await desktop.processes(osuDir)).map((process) => process.pid));
}
async stop() {
this.launchedWith = null;
this.window = null;
this.verified = false;
await this.stopProcesses();
this.set('stopped');
}
}
// ── One-time sign-in ────────────────────────────────────────────────────
/**
* Moves tournament.cfg aside so osu! starts normally (for signing in), and
* puts it back afterwards. Returns a function that restores it.
*/
export async function normalModeFor(osuDir: string) {
const cfg = join(osuDir, 'tournament.cfg');
const aside = join(osuDir, 'tournament.cfg.vash-signin');
if (existsSync(cfg)) await rename(cfg, aside);
return async () => {
if (existsSync(aside)) {
await rm(cfg, { force: true });
await rename(aside, cfg);
}
};
}

169
streamer/windows/viewer.ts Normal file
View File

@@ -0,0 +1,169 @@
/**
* A minimal remote screen for the one-time spectator sign-in, for someone who
* can only reach the PC over SSH. It shows the PC's screen a few times a
* second and forwards clicks and typing. It listens on 127.0.0.1 only and
* needs the random token printed by `vash-broadcast login`; reach it through
* an SSH tunnel. It exists only while the sign-in runs.
*/
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { Server } from 'bun';
import { authorized } from './control';
import type { Desktop } from './desktop';
/** Keys the page can send as virtual-key codes. */
const KEYS: Record<string, number> = {
Enter: 0x0d,
Tab: 0x09,
Backspace: 0x08,
Escape: 0x1b,
Delete: 0x2e,
ArrowLeft: 0x25,
ArrowUp: 0x26,
ArrowRight: 0x27,
ArrowDown: 0x28,
Home: 0x24,
End: 0x23
};
export interface ViewerOptions {
port: number;
token: string;
desktop: Desktop;
scratch: string;
title: string;
status: () => { message: string; done: boolean };
onFinish: () => void;
}
export function startViewer(options: ViewerOptions): Server<undefined> {
const { desktop } = options;
let screen = { width: 1920, height: 1080 };
let capturing: Promise<Uint8Array> | null = null;
const frame = join(options.scratch, 'viewer-frame.jpg');
const grab = async () => {
const session = await desktop.session();
screen = { width: session.screenWidth, height: session.screenHeight };
await desktop.capture({
x: 0,
y: 0,
width: screen.width,
height: screen.height,
path: frame,
format: 'jpeg',
maxWidth: 1280,
quality: 70
});
return new Uint8Array(await readFile(frame));
};
return Bun.serve({
hostname: '127.0.0.1',
port: options.port,
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === '/')
return new Response(page(options.title), {
headers: { 'content-type': 'text/html; charset=utf-8' }
});
if (!authorized(request, options.token)) return new Response('Forbidden', { status: 403 });
try {
if (url.pathname === '/frame') {
capturing ??= grab().finally(() => (capturing = null));
return new Response(await capturing, {
headers: { 'content-type': 'image/jpeg', 'cache-control': 'no-store' }
});
}
if (url.pathname === '/status') return Response.json(options.status());
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
if (url.pathname === '/click' && request.method === 'POST') {
const x = Math.round(Math.max(0, Math.min(1, Number(body.x))) * (screen.width - 1));
const y = Math.round(Math.max(0, Math.min(1, Number(body.y))) * (screen.height - 1));
return Response.json({ ok: await desktop.click(x, y, body.right === true) });
}
if (url.pathname === '/type' && request.method === 'POST') {
const text = String(body.text ?? '').slice(0, 200);
return Response.json({ ok: (await desktop.type(text)) === text.length });
}
if (url.pathname === '/key' && request.method === 'POST') {
const vk = KEYS[String(body.key)];
if (!vk) return Response.json({ ok: false });
await desktop.key(vk);
return Response.json({ ok: true });
}
if (url.pathname === '/finish' && request.method === 'POST') {
options.onFinish();
return Response.json({ ok: true });
}
} catch (err) {
return Response.json({ ok: false, error: (err as Error).message }, { status: 500 });
}
return new Response('Not found', { status: 404 });
}
});
}
function page(title: string) {
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title.replace(/[<>&]/g, '')}</title>
<style>
:root { color-scheme: dark; --bg: #0c0c10; --panel: #17171d; --text: #ececf1; --muted: #9a9aa8; --accent: #ff66aa; }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 system-ui, sans-serif; }
header { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; padding: 12px 16px; background: var(--panel); }
header strong { font-size: 15px; }
#status { color: var(--muted); flex: 1; min-width: 200px; }
main { padding: 12px 16px; }
#screen { display: block; max-width: 100%; border-radius: 8px; outline: none; cursor: crosshair; background: #000; }
#screen:focus { box-shadow: 0 0 0 2px var(--accent); }
.row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; align-items: center; }
button, input { font: inherit; color: var(--text); background: #24242c; border: 1px solid #34343e; border-radius: 8px; padding: 6px 10px; }
button:hover { background: #2e2e38; }
button.primary { background: var(--accent); border-color: var(--accent); color: #111; font-weight: 600; }
input { min-width: 220px; }
p { color: var(--muted); max-width: 70ch; }
</style></head><body>
<header><strong>${title.replace(/[<>&]/g, '')}</strong><span id="status">Connecting…</span>
<button class="primary" id="finish">I have signed in: finish</button></header>
<main>
<p>Sign in to osu! with the spectator account and tick <b>Remember username</b> and <b>Remember password</b>. Click the screen to focus it; typing is then sent to the PC. Use the box below for passwords if your keyboard layout differs.</p>
<img id="screen" tabindex="0" alt="The broadcast PC's screen">
<div class="row"><input id="text" type="password" placeholder="Text to type on the PC" autocomplete="off">
<button id="send">Type it</button><button data-key="Enter">Enter</button><button data-key="Tab">Tab</button>
<button data-key="Backspace">Backspace</button><button data-key="Escape">Esc</button></div>
</main>
<script>
const token = decodeURIComponent(location.hash.slice(1));
const headers = { 'x-vash-token': token, 'content-type': 'application/json' };
const img = document.getElementById('screen');
const status = document.getElementById('status');
const post = (path, body) => fetch(path, { method: 'POST', headers, body: JSON.stringify(body || {}) }).then(r => r.json()).catch(() => ({}));
let url = null;
async function refresh() {
try {
const r = await fetch('/frame', { headers });
if (r.ok) { const next = URL.createObjectURL(await r.blob()); img.src = next; if (url) URL.revokeObjectURL(url); url = next; }
else if (r.status === 403) { status.textContent = 'Wrong or missing token: open the exact link vash-broadcast login printed.'; return; }
} catch {}
setTimeout(refresh, 400);
}
async function poll() {
const s = await fetch('/status', { headers }).then(r => r.json()).catch(() => null);
if (s) { status.textContent = s.message; if (s.done) return; }
setTimeout(poll, 1500);
}
img.addEventListener('click', e => { const r = img.getBoundingClientRect(); img.focus(); post('/click', { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height }); });
img.addEventListener('contextmenu', e => { e.preventDefault(); const r = img.getBoundingClientRect(); post('/click', { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height, right: true }); });
img.addEventListener('keydown', e => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
e.preventDefault();
if (e.key.length === 1) post('/type', { text: e.key }); else post('/key', { key: e.key });
});
document.getElementById('send').onclick = () => { const t = document.getElementById('text'); post('/type', { text: t.value }); t.value = ''; };
document.querySelectorAll('[data-key]').forEach(b => b.onclick = () => post('/key', { key: b.dataset.key }));
document.getElementById('finish').onclick = () => post('/finish');
refresh(); poll();
</script></body></html>`;
}

View File

@@ -0,0 +1,131 @@
import { afterEach, beforeEach, expect, test } from 'bun:test';
import { existsSync } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Server } from 'bun';
import { layout, portsForSlot, readJson, saveConfig } from './config';
import { callWorker } from './control';
import { FakeDesktop } from './fakes';
import { runWorker, type WorkerState } from './worker';
const CHANNEL = '0b7f3c2a-1d2e-4f5a-8b9c-0d1e2f3a4b5c';
let home: string;
let vash: Server<undefined>;
const received: Record<string, unknown>[] = [];
const posts: Record<string, unknown>[] = [];
let live = false;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'vash-worker-'));
process.env.VASH_BROADCAST_HOME = home;
received.length = 0;
posts.length = 0;
live = false;
vash = Bun.serve({
port: 0,
async fetch(request, server) {
const url = new URL(request.url);
if (url.pathname === '/ws/agent')
return server.upgrade(request) ? undefined : new Response('no');
if (request.headers.get('authorization') !== 'Bearer vash_test')
return new Response('no', { status: 401 });
if (url.pathname === `/api/broadcast/${CHANNEL}/stream` && request.method === 'GET')
return Response.json({
live,
name: 'Main',
width: 1920,
height: 1080,
fps: 60,
bitrateKbps: 6000,
overlayUrl: `${url.origin}/broadcast/${CHANNEL}/overlay`,
record: false,
destinations: []
});
if (url.pathname === `/api/broadcast/${CHANNEL}/stream`) {
const body = (await request.json()) as Record<string, unknown>;
posts.push(body);
return Response.json(
body.event === 'start' ? { id: 'session-1', storagePrefix: null } : { ok: true }
);
}
return new Response('not found', { status: 404 });
},
websocket: {
message(ws, raw) {
const message = JSON.parse(String(raw));
received.push(message);
if (message.type === 'hello') {
ws.send(JSON.stringify({ type: 'welcome', channel: 'Main' }));
ws.send(
JSON.stringify({
type: 'follow',
scene: 'idle',
matchId: null,
lobbyId: null,
roomName: null,
teamSize: null,
title: null
})
);
}
}
}
});
await saveConfig({
version: 1,
name: 'test',
server: `http://127.0.0.1:${vash.port}`,
channel: CHANNEL,
token: 'vash_test',
access: null,
ports: portsForSlot(40 + Math.floor(Math.random() * 9)),
obsPassword: 'pw',
ingestKey: 'key',
osuSource: null,
tourneyHeight: 720,
encoder: 'amd',
autostart: false
});
});
afterEach(async () => {
vash.stop(true);
delete process.env.VASH_BROADCAST_HOME;
await rm(home, { recursive: true, force: true });
});
test('runs without its programs, reports why, follows Go live, and stops cleanly', async () => {
const paths = layout('test');
const desktop = new FakeDesktop();
const done = runWorker('test', desktop);
let status: WorkerState | null = null;
for (let waited = 0; waited < 80 && !status?.vash.connected; waited++) {
await Bun.sleep(100);
status = await callWorker<WorkerState>(paths.runtime, '/status');
}
expect(status?.vash).toMatchObject({ connected: true, channel: 'Main' });
// OBS is not installed here, so the worker says it is (re)starting it rather than claiming success.
expect(['starting', 'reconnecting']).toContain(status!.state);
expect(status!.tourney.state).not.toBe('spectating');
expect(received.some((message) => message.type === 'status' && message.instance === 'test')).toBe(
true
);
expect(existsSync(paths.lock)).toBe(true);
live = true;
for (let waited = 0; waited < 80 && !posts.length; waited++) await Bun.sleep(100);
expect(posts[0]).toEqual({ event: 'start' });
expect(await callWorker<{ ok: boolean }>(paths.runtime, '/stop', { method: 'POST' })).toEqual({
ok: true
});
expect(await done).toBe(0);
expect(posts.at(-1)).toMatchObject({ event: 'end', sessionId: 'session-1' });
expect(existsSync(paths.runtime)).toBe(false);
expect(existsSync(paths.lock)).toBe(false);
expect((await readJson<WorkerState>(paths.state))?.state).toBe('stopped');
expect(
received.some((message) => message.type === 'status' && message.state === 'stopping')
).toBe(true);
}, 30_000);

900
streamer/windows/worker.ts Normal file
View File

@@ -0,0 +1,900 @@
/**
* The unattended worker for one instance, running in the signed-in desktop:
*
* Vash (/ws/agent) ──follow──▶ osu!tourney: right team size, right room
* tosu ──live scores──▶ Vash; tosu ──team names──▶ room confirmed
* Vash (/stream) ──Go live──▶ relay listening ──▶ OBS streams into it
* OBS: overlay over the captured tourney window, AMD hardware encoding
* relay ──▶ every destination + HLS recording on the Vash site
*
* It restarts whatever stops (OBS, tosu, osu!tourney, the relay), reports what
* it is doing to Vash and to state.json, and stops cleanly when asked.
*/
import { existsSync } from 'node:fs';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { VashLink, type Follow } from '../link';
import { Obs } from '../obs';
import { Broadcaster, RtmpIngest } from '../relay';
import { TosuFeed } from '../tosu';
import { VashApi, type StreamConfig } from '../vash-api';
import {
accessHeaders,
layout,
loadConfig,
readJson,
secret,
VERSION,
writeJson,
type InstanceConfig,
type Layout
} from './config';
import { authorized, callWorker, type Runtime } from './control';
import { WindowsDesktop, type Desktop, type SessionInfo } from './desktop';
import { acquireLock, lockOwner, releaseLock } from './lock';
import { fileLogger } from './log';
import { ObsScene } from './obs-control';
import { encoderFromLog, latestObsLog, OBS_ARGS, writeObsSettings } from './obs-files';
import { startAccessProxy } from './proxy';
import { Supervised, type Child } from './supervise';
import { normalModeFor, pickMainWindow, readSavedLogin, TourneyController } from './tourney';
import { startViewer } from './viewer';
export interface WorkerState {
instance: string;
version: string;
pid: number;
state: string;
detail: string | null;
updatedAt: string;
startedAt: string;
onAir: boolean;
live: boolean;
obsStreaming: boolean;
relayReceiving: boolean;
encoder: string | null;
match: {
id: string | null;
title: string | null;
lobbyId: number | null;
roomName: string | null;
} | null;
tourney: { state: string; detail: string | null; verified: boolean };
components: Record<string, unknown>;
destinations: { label: string; state: string; error: string | null }[];
vash: { connected: boolean; rejected: boolean; channel: string | null; lastError: string | null };
desktop: SessionInfo | null;
warnings: string[];
}
/** The same program, as a command line: the compiled exe, or bun + this script in development. */
export function selfCommand(...args: string[]) {
const compiled =
Bun.main.startsWith('B:/~BUN') || Bun.main.startsWith('/$bunfs') || Bun.main.includes('~BUN');
return compiled ? [process.execPath, ...args] : [process.execPath, Bun.main, ...args];
}
const childOf = (process: import('bun').Subprocess): Child => ({
pid: process.pid,
exited: process.exited,
kill: () => process.kill()
});
/** Stops every process whose program lives in this instance's folder. */
export async function stopInstanceProcesses(
desktop: Desktop,
paths: Layout,
log: (...parts: unknown[]) => void
) {
const processes = await desktop.processes(paths.dir);
if (!processes.length) return 0;
log(`Stopping ${processes.length} leftover process(es) of this instance`);
return desktop.kill(processes.map((process) => process.pid));
}
/** Runs until asked to stop. Tests pass a fake desktop; Windows uses the PowerShell helper. */
export async function runWorker(name: string, desktop?: Desktop): Promise<number> {
const config = await loadConfig(name);
const paths = layout(name);
const log = fileLogger(paths.log);
log(
`vash-broadcast ${VERSION} worker starting for "${name}": ${config.server}, channel ${config.channel}`
);
const worker = new Worker(
config,
paths,
log,
desktop ?? new WindowsDesktop(paths.helperScript, log)
);
return worker.run();
}
class Worker {
private desktop: Desktop;
private api: VashApi;
private link: VashLink;
private tosu: TosuFeed;
private tourney: TourneyController;
private obs: Obs;
private scene: ObsScene;
private ingest: RtmpIngest;
private broadcaster: Broadcaster;
private obsProcess: Supervised;
private tosuProcess: Supervised;
private stopRequested = false;
private stopping = false;
private blockedBy: string | null = null;
private stream: StreamConfig | null = null;
private streamError: string | null = null;
private session: SessionInfo | null = null;
private follow: Follow | null = null;
private obsLaunchedWith = '';
private obsConnectedAt = 0;
private obsConnecting = false;
private obsStreaming = false;
private lastStartStream = 0;
private streamingSince = 0;
private encoder: string | null = null;
private warnings = new Set<string>();
private startedAt = new Date();
private controlToken = secret();
private headers: Record<string, string>;
private overlayUrl: string | null = null;
constructor(
private config: InstanceConfig,
private paths: Layout,
private log: (...parts: unknown[]) => void,
desktop: Desktop
) {
this.headers = accessHeaders(config);
this.desktop = desktop;
this.api = new VashApi(config.server, config.channel, config.token, this.headers);
this.tosu = new TosuFeed(
`ws://127.0.0.1:${config.ports.tosu}/websocket/v2`,
(frame) => this.link.sendScores(frame),
log
);
this.link = new VashLink({
server: config.server,
channel: config.channel,
token: config.token,
headers: this.headers,
log,
onFollow: (follow) => {
const changed =
follow.matchId !== this.follow?.matchId || follow.lobbyId !== this.follow?.lobbyId;
this.follow = follow;
if (changed)
log(
follow.title
? `Vash: now showing ${follow.title}${follow.lobbyId ? ` (mp/${follow.lobbyId})` : ''}`
: 'Vash: nothing to show'
);
this.tourney.follow(follow);
}
});
this.tourney = new TourneyController({
osuDir: paths.osu,
snapshots: paths.snapshots,
height: config.tourneyHeight,
windowTitle: `Vash osu!tourney (${config.name})`,
desktop: this.desktop,
tosu: this.tosu,
log
});
this.obs = new Obs(`ws://127.0.0.1:${config.ports.obs}`, config.obsPassword);
this.obs.onClose = (reason) => {
log(`OBS websocket closed: ${reason}`);
this.obsConnectedAt = 0;
this.obsStreaming = false;
this.scene.reset();
};
this.scene = new ObsScene(this.obs, log);
this.ingest = new RtmpIngest({
ffmpeg: paths.ffmpeg,
host: '127.0.0.1',
port: config.ports.ingest,
key: config.ingestKey,
log
});
this.broadcaster = new Broadcaster({
api: this.api,
ffmpeg: paths.ffmpeg,
hlsDir: paths.hls,
source: this.ingest,
log
});
this.obsProcess = new Supervised('OBS', () => this.launchObs(), { log });
this.tosuProcess = new Supervised(
'tosu',
() =>
childOf(
Bun.spawn([paths.tosuExe], {
cwd: paths.tosu,
stdout: 'ignore',
stderr: 'ignore',
windowsHide: true
})
),
{ log }
);
}
private async launchObs(): Promise<Child> {
const stream = this.stream;
const settings = {
websocketPort: this.config.ports.obs,
websocketPassword: this.config.obsPassword,
width: stream?.width ?? 1920,
height: stream?.height ?? 1080,
fps: stream?.fps ?? 60,
bitrateKbps: stream?.bitrateKbps ?? 6000,
encoder: this.config.encoder,
ingestUrl: this.ingest.url,
ingestKey: this.config.ingestKey
};
await writeObsSettings(this.paths.obsConfig, settings);
// Started before Vash answered: restart once the channel's real settings are known.
this.obsLaunchedWith = this.videoKey(stream) || 'defaults';
this.scene.reset();
const process = Bun.spawn([this.paths.obsExe, ...OBS_ARGS], {
cwd: dirname(this.paths.obsExe),
stdout: 'ignore',
stderr: 'ignore'
});
this.log(
`OBS started (${settings.width}×${settings.height} ${settings.fps} fps, ${settings.bitrateKbps} kbps, ${settings.encoder})`
);
return childOf(process);
}
/** OBS must restart (while off air) when these change. */
private videoKey(stream: StreamConfig | null) {
return stream ? `${stream.width}x${stream.height}@${stream.fps}/${stream.bitrateKbps}` : '';
}
async run(): Promise<number> {
const { paths, config, log } = this;
const server = Bun.serve({
hostname: '127.0.0.1',
port: config.ports.control,
fetch: async (request) => {
if (!authorized(request, this.controlToken))
return new Response('Forbidden', { status: 403 });
const url = new URL(request.url);
if (url.pathname === '/status') return Response.json(this.state());
if (url.pathname === '/stop' && request.method === 'POST') {
this.stopRequested = true;
return Response.json({ ok: true });
}
if (url.pathname === '/snapshot' && request.method === 'POST')
return Response.json(await this.snapshot());
return new Response('Not found', { status: 404 });
}
});
const runtime: Runtime = {
pid: process.pid,
watchdogPid: Number(process.env.VASH_WATCHDOG_PID) || null,
port: config.ports.control,
token: this.controlToken,
startedAt: this.startedAt.toISOString(),
version: VERSION
};
await writeJson(paths.runtime, runtime);
process.on('SIGTERM', () => (this.stopRequested = true));
process.on('SIGINT', () => (this.stopRequested = true));
// Leftovers of a crashed run would hold ports and windows; this instance's only.
await stopInstanceProcesses(this.desktop, paths, log).catch((err) => log('Cleanup:', err));
let proxy: ReturnType<typeof startAccessProxy> | null = null;
if (config.access) {
proxy = startAccessProxy({
port: config.ports.proxy,
upstream: config.server,
headers: this.headers,
log
});
log(`Overlay proxy for Cloudflare Access on 127.0.0.1:${config.ports.proxy}`);
}
this.link.start();
this.tosu.start();
// Sleep or a switched-off display would stop capture in the middle of a match.
await this.desktop.stayAwake(true).catch((err) => log('Could not keep the PC awake:', err));
let tick = 0;
while (!this.stopRequested) {
tick++;
try {
await this.step(tick);
} catch (err) {
log('Worker step failed:', err);
}
await this.writeState();
await Bun.sleep(1000);
}
await this.shutdown();
proxy?.stop(true);
server.stop(true);
return 0;
}
private async step(tick: number) {
const { paths, config, log } = this;
// One osu!tourney + tosu stack per PC.
if (this.blockedBy !== null || tick === 1) {
const lock = await acquireLock(paths.lock, config.name);
if (!lock.ok) {
if (this.blockedBy !== lock.owner.instance)
log(
`Waiting: instance "${lock.owner.instance}" runs osu!tourney on this PC (pid ${lock.owner.pid})`
);
this.blockedBy = lock.owner.instance;
// A holder whose worker has not answered for a minute is gone (its pid was reused).
const answering = await this.ownerAnswers(lock.owner.instance);
if (answering) this.silentSince = 0;
else if (!this.silentSince) this.silentSince = Date.now();
else if (Date.now() - this.silentSince > 60_000) {
log(
`Instance "${lock.owner.instance}" holds the tourney lock but does not answer; taking over`
);
await rm(paths.lock, { force: true });
this.silentSince = 0;
}
this.link.report(this.report());
await Bun.sleep(4000);
return;
}
if (this.blockedBy) log('The tourney stack is free; starting');
this.blockedBy = null;
}
if (tick % 2 === 1) this.session = await this.desktop.session().catch(() => null);
if (tick % 5 === 1) {
try {
const stream = await this.api.stream();
// OBS on this PC loads the overlay itself; behind Access, through the local proxy.
if (config.access)
stream.overlayUrl = stream.overlayUrl.replace(
/^https?:\/\/[^/]+/,
`http://127.0.0.1:${config.ports.proxy}`
);
this.stream = stream;
this.streamError = null;
} catch (err) {
if (this.streamError !== (err as Error).message) log('Vash stream settings:', err);
this.streamError = (err as Error).message;
}
}
// Programs: tosu and OBS always; osu!tourney once Vash said what to show (or after 15 s).
await this.tosuProcess.ensure();
if (
this.stream &&
this.obsLaunchedWith &&
this.obsLaunchedWith !== this.videoKey(this.stream) &&
!this.broadcaster.onAir
) {
log('Channel video settings changed; restarting OBS');
await this.stopObs();
}
await this.obsProcess.ensure();
await this.connectObs();
if (this.follow || Date.now() - this.startedAt.getTime() > 15_000)
if (tick % 2 === 0) await this.tourney.tick(this.session);
if (this.obs.connected && this.stream && tick % 3 === 0) {
const window = this.tourney.window;
await this.scene
.reconcile({
canvas: { width: this.stream.width, height: this.stream.height },
fps: this.stream.fps,
overlayUrl: this.stream.overlayUrl,
capture: window
? {
title: `Vash osu!tourney (${config.name})`,
className: window.className,
exe: 'osu!.exe'
}
: null,
showGameplay: this.tourney.state === 'spectating',
ingest: { server: this.ingest.url, key: config.ingestKey }
})
.catch((err) => log('OBS scene:', err));
}
// On and off air, following the channel's Go live switch.
if (this.stream && tick % 5 === 1)
await this.broadcaster.apply(this.stream).catch((err) => log('Broadcast:', err));
await this.broadcaster.tick();
await this.syncObsStream();
this.link.report(this.report());
}
private silentSince = 0;
private async ownerAnswers(instance: string) {
try {
return !!(await callWorker(layout(instance).runtime, '/status'));
} catch {
return false;
}
}
private async connectObs() {
if (!this.obsProcess.running || this.obs.connected || this.obsConnecting) return;
this.obsConnecting = true;
try {
await this.obs.connect();
this.obsConnectedAt = Date.now();
this.log('OBS websocket connected');
const encoder = await this.scene.profileEncoder().catch(() => null);
if (this.config.encoder === 'amd' && encoder && encoder !== 'amd')
this.warn(
`OBS switched the encoder to ${encoder}: the AMD hardware encoder is not available (see OBS's log)`
);
} catch (err) {
const upFor = Date.now() - this.obsProcess.startedAt;
if (upFor > 90_000) {
this.log(
`OBS is running but its websocket does not answer (${(err as Error).message}); restarting OBS`
);
await this.stopObs();
}
} finally {
this.obsConnecting = false;
}
}
/** OBS streams into the relay while on air, and not otherwise. */
private async syncObsStream() {
if (!this.obs.connected) {
this.obsStreaming = false;
return;
}
const status = await this.scene.streaming().catch(() => null);
if (!status) return;
const was = this.obsStreaming;
this.obsStreaming = status.outputActive;
if (this.obsStreaming && !was) {
this.streamingSince = Date.now();
this.encoder = null;
}
const wanted =
this.broadcaster.onAir &&
this.ingest.running &&
Date.now() - this.ingest.listeningSince > 1500;
if (wanted && !status.outputActive && Date.now() - this.lastStartStream > 10_000) {
this.lastStartStream = Date.now();
this.log('Starting the OBS stream into the relay');
await this.scene.startStream().catch((err) => this.log('OBS StartStream:', err));
} else if (!this.broadcaster.onAir && status.outputActive) {
this.log('Stopping the OBS stream');
await this.scene.stopStream().catch((err) => this.log('OBS StopStream:', err));
}
// Which encoder actually runs, from OBS's own log, a few seconds into the stream.
if (this.obsStreaming && !this.encoder && Date.now() - this.streamingSince > 5000) {
const found = encoderFromLog(await latestObsLog(this.paths.obsConfig));
this.encoder =
found === 'amd'
? 'AMD AMF (hardware)'
: found === 'x264'
? 'x264 (software)'
: (found ?? 'unknown');
this.log(`OBS encoder in use: ${this.encoder}`);
if (this.config.encoder === 'amd' && found !== 'amd')
this.warn(`Streaming with ${this.encoder}, not the AMD hardware encoder`);
}
}
private warn(message: string) {
if (this.warnings.has(message)) return;
this.warnings.add(message);
this.log(`Warning: ${message}`);
}
private async stopObs() {
this.obs.close();
this.obsConnectedAt = 0;
await this.obsProcess.stop(async (child) => {
await this.desktop.close(child.pid);
});
}
/** The one state shown on the site and in `vash-broadcast status`. */
private overall(): { state: string; detail: string | null } {
if (this.stopping) return { state: 'stopping', detail: null };
if (this.blockedBy)
return {
state: 'blocked',
detail: `Instance "${this.blockedBy}" is running osu!tourney on this PC`
};
if (this.link.rejected)
return {
state: 'error',
detail: 'Vash refused the agent token; run setup again with a new token'
};
if (this.streamError && !this.stream)
return { state: 'error', detail: `Cannot reach Vash: ${this.streamError}` };
const tourney = this.tourney.state;
if (tourney === 'login-required')
return { state: 'login-required', detail: this.tourney.detail };
if (!this.obs.connected)
return {
state: this.obsProcess.restarts ? 'reconnecting' : 'starting',
detail: this.obsProcess.lastExit ? `OBS ${this.obsProcess.lastExit}` : 'starting OBS'
};
if (this.broadcaster.onAir && !this.obsStreaming && Date.now() - this.lastStartStream > 20_000)
return { state: 'reconnecting', detail: 'On air, but OBS is not streaming into the relay' };
if (tourney === 'stopped')
return { state: 'starting', detail: 'waiting for Vash to say what to show' };
const map: Record<string, string> = {
starting: 'starting',
'desktop-locked': 'desktop-locked',
idle: 'idle',
'waiting-for-lobby': 'waiting-for-lobby',
selecting: 'selecting',
spectating: 'spectating',
error: 'error'
};
return { state: map[tourney] ?? 'error', detail: this.tourney.detail };
}
private report() {
const { state, detail } = this.overall();
return {
state,
detail,
onAir: this.broadcaster.onAir && this.obsStreaming,
encoder: this.encoder,
instance: this.config.name,
version: VERSION
};
}
state(): WorkerState {
const { state, detail } = this.overall();
const follow = this.follow;
return {
instance: this.config.name,
version: VERSION,
pid: process.pid,
state,
detail,
updatedAt: new Date().toISOString(),
startedAt: this.startedAt.toISOString(),
onAir: this.broadcaster.onAir,
live: !!this.stream?.live,
obsStreaming: this.obsStreaming,
relayReceiving: this.ingest.receiving,
encoder: this.encoder,
match: follow
? {
id: follow.matchId,
title: follow.title,
lobbyId: follow.lobbyId,
roomName: follow.roomName
}
: null,
tourney: {
state: this.tourney.state,
detail: this.tourney.detail,
verified: this.tourney.verified
},
components: {
obs: { ...this.obsProcess.status(), websocket: this.obs.connected },
tosu: {
...this.tosuProcess.status(),
connected: this.tosu.connected,
players: this.tosu.fresh()?.clients.length ?? 0,
frameAt: this.tosu.latestAt ? new Date(this.tosu.latestAt).toISOString() : null
},
scores: {
players: this.link.scores.players,
sentAt: this.link.scores.sentAt ? new Date(this.link.scores.sentAt).toISOString() : null,
acceptedAt: this.link.scores.acceptedAt
? new Date(this.link.scores.acceptedAt).toISOString()
: null
},
relay: {
listening: this.ingest.running,
receiving: this.ingest.receiving,
recording: this.broadcaster.recording
}
},
destinations: this.broadcaster
.destinations()
.map(({ label, state, error }) => ({ label, state, error })),
vash: {
connected: this.link.connected,
rejected: this.link.rejected,
channel: this.link.channelName,
lastError: this.streamError
},
desktop: this.session,
warnings: [...this.warnings]
};
}
private lastStateWrite = 0;
private async writeState(force = false) {
if (!force && Date.now() - this.lastStateWrite < 2000) return;
this.lastStateWrite = Date.now();
await writeJson(this.paths.state, this.state()).catch(() => {});
}
/** Saves what OBS outputs and what the tourney window shows, for checking from SSH. */
private async snapshot() {
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
await mkdir(this.paths.snapshots, { recursive: true });
const files: string[] = [];
const errors: string[] = [];
if (this.obs.connected) {
try {
const file = join(this.paths.snapshots, `${stamp}-obs-output.png`);
await writeFile(file, await this.scene.screenshot());
files.push(file);
} catch (err) {
errors.push(`OBS: ${(err as Error).message}`);
}
} else errors.push('OBS is not connected');
const window = this.tourney.window;
if (window) {
try {
const file = join(this.paths.snapshots, `${stamp}-tourney-window.png`);
await this.desktop.capture({
x: window.clientX,
y: window.clientY,
width: window.clientWidth,
height: window.clientHeight,
path: file
});
files.push(file);
} catch (err) {
errors.push(`Tourney window: ${(err as Error).message}`);
}
} else errors.push('No osu!tourney window');
const panel = join(this.paths.snapshots, 'tourney-panel.png');
if (existsSync(panel)) files.push(panel, join(this.paths.snapshots, 'tourney-panel.json'));
return { files, errors };
}
private async shutdown() {
const { log, paths } = this;
this.stopping = true;
log('Stopping');
this.link.report(this.report());
await this.writeState(true);
if (this.obs.connected && this.obsStreaming) await this.scene.stopStream().catch(() => {});
await this.broadcaster.stop().catch((err) => log('Going off air:', err));
await this.tourney.stop().catch((err) => log('Stopping osu!tourney:', err));
await this.stopObs().catch(() => {});
await this.tosuProcess.stop();
this.tosu.stop();
this.link.stop();
await stopInstanceProcesses(this.desktop, paths, log).catch(() => {});
this.desktop.stop();
await releaseLock(paths.lock);
await writeJson(paths.state, { ...this.state(), state: 'stopped', detail: null }).catch(
() => {}
);
await rm(paths.runtime, { force: true });
log('Stopped');
}
}
// ── Watchdog ────────────────────────────────────────────────────────────
/**
* What the scheduled task runs. It restarts the worker if it crashes (with
* backoff) and exits when the worker stops on purpose. The request file says
* whether this start is the worker, the one-time sign-in, or a diagnosis.
*/
export async function runTask(name: string): Promise<number> {
const paths = layout(name);
const log = fileLogger(paths.log);
const request = await readJson<{ mode: string; at: number }>(paths.request);
await rm(paths.request, { force: true });
const mode = request && Date.now() - request.at < 5 * 60_000 ? request.mode : 'run';
if (mode === 'login') return runSignIn(name);
if (mode === 'doctor') return runDesktopDoctor(name);
const owner = await lockOwner(paths.lock);
const runtime = await readJson<Runtime>(paths.runtime);
if (runtime && owner?.pid === runtime.pid) {
log('The worker is already running; not starting another');
return 0;
}
let failures = 0;
for (;;) {
const started = Date.now();
const child = Bun.spawn(selfCommand('worker', '--instance', name), {
stdout: 'ignore',
stderr: 'pipe',
env: { ...process.env, VASH_WATCHDOG_PID: String(process.pid) },
windowsHide: true
});
let stderr = '';
void (async () => {
for await (const chunk of child.stderr)
stderr = (stderr + new TextDecoder().decode(chunk)).slice(-2000);
})();
const code = await child.exited;
if (code === 0) return 0;
if (Date.now() - started > 10 * 60_000) failures = 0;
const wait = Math.min(60_000, 2000 * 2 ** failures++);
log(
`Worker exited with ${code}${stderr ? `: ${stderr.trim().split('\n').slice(-3).join(' ')}` : ''}; restarting in ${Math.round(wait / 1000)}s`
);
if (existsSync(paths.request)) return 0;
await Bun.sleep(wait);
}
}
// ── One-time sign-in ────────────────────────────────────────────────────
export interface SignInState {
state: 'starting' | 'waiting' | 'closing' | 'done' | 'failed';
message: string;
port: number;
token: string;
username: string | null;
updatedAt: string;
}
async function runSignIn(name: string): Promise<number> {
const config = await loadConfig(name);
const paths = layout(name);
const log = fileLogger(paths.log);
const desktop = new WindowsDesktop(paths.helperScript, log);
const token = secret();
let finish = false;
let state: SignInState = {
state: 'starting',
message: 'Starting osu!…',
port: config.ports.viewer,
token,
username: null,
updatedAt: new Date().toISOString()
};
const set = async (next: Partial<SignInState>) => {
state = { ...state, ...next, updatedAt: new Date().toISOString() };
await writeJson(paths.login, state);
};
log('One-time sign-in: starting osu! normally');
await stopInstanceProcesses(desktop, paths, log).catch(() => {});
const restore = await normalModeFor(paths.osu);
const viewer = startViewer({
port: config.ports.viewer,
token,
desktop,
scratch: paths.snapshots,
title: `Vash broadcast sign-in (${name})`,
status: () => ({
message: state.message,
done: state.state === 'done' || state.state === 'failed'
}),
onFinish: () => (finish = true)
});
await mkdir(paths.snapshots, { recursive: true });
try {
Bun.spawn([paths.osuExe], { cwd: paths.osu, stdout: 'ignore', stderr: 'ignore' });
await set({
state: 'waiting',
message:
'osu! is starting (a fresh installation downloads its files first). Sign in with the spectator account, tick Remember username and Remember password, then press Finish.'
});
const deadline = Date.now() + 45 * 60_000;
while (!finish && Date.now() < deadline) {
await Bun.sleep(1000);
const window = pickMainWindow(await desktop.windows(paths.osu).catch(() => []));
if (window && state.message.startsWith('osu! is starting'))
await desktop.focus(window.hwnd).catch(() => false);
}
await set({ state: 'closing', message: 'Closing osu! so it saves the login…' });
for (const process of await desktop.processes(paths.osu))
await desktop.close(process.pid).catch(() => 0);
for (
let waited = 0;
waited < 20_000 && (await desktop.processes(paths.osu)).length;
waited += 1000
)
await Bun.sleep(1000);
await stopInstanceProcesses(desktop, paths, log).catch(() => {});
await restore();
const login = await readSavedLogin(paths.osu);
if (login.saved) {
await set({
state: 'done',
username: login.username,
message: `Signed in as ${login.username}; the login is saved.`
});
log(`One-time sign-in done: ${login.username}`);
} else {
await set({
state: 'failed',
message: finish
? 'osu! did not save a login. Run vash-broadcast login again and tick Remember username and Remember password.'
: 'The sign-in timed out after 45 minutes.'
});
}
} catch (err) {
await restore().catch(() => {});
await set({ state: 'failed', message: (err as Error).message });
} finally {
await Bun.sleep(3000);
viewer.stop(true);
desktop.stop();
}
return 0;
}
// ── Diagnosis inside the desktop ─────────────────────────────────────────
async function runDesktopDoctor(name: string): Promise<number> {
const paths = layout(name);
const log = fileLogger(paths.log);
const desktop = new WindowsDesktop(paths.helperScript, log);
const checks: { name: string; ok: boolean; detail: string }[] = [];
const check = async (label: string, run: () => Promise<string>) => {
try {
checks.push({ name: label, ok: true, detail: await run() });
} catch (err) {
checks.push({ name: label, ok: false, detail: (err as Error).message });
}
};
await check('Desktop session', async () => {
const session = await desktop.session();
if (session.inputDesktop !== 'Default')
throw new Error(
`The desktop is locked or on a secure screen (${session.inputDesktop ?? 'no input desktop'}); rooms cannot be clicked`
);
if (session.sessionId !== session.consoleSessionId)
throw new Error(
`This session (${session.sessionId}) is not the one on the PC's screen (${session.consoleSessionId}); a disconnected Remote Desktop session has no display`
);
return `unlocked, ${session.screenWidth}×${session.screenHeight}, idle ${Math.round(session.idleMs / 1000)}s`;
});
await check('Screen capture', async () => {
await mkdir(paths.snapshots, { recursive: true });
const file = join(paths.snapshots, 'doctor-screen.jpg');
const size = await desktop.capture({
x: 0,
y: 0,
width: 640,
height: 360,
path: file,
format: 'jpeg'
});
return `captured ${size.width}×${size.height} to ${file}`;
});
await check('Windows OCR', async () => {
const ready = await desktop.ocrReady();
return `language ${ready.language}, images up to ${ready.maxDimension} px`;
});
await check('osu!tourney window', async () => {
const windows = await desktop.windows(paths.osu);
return windows.length
? windows
.map((window) => `"${window.title}" ${window.clientWidth}×${window.clientHeight}`)
.join('; ')
: 'osu! is not running (normal while the worker is stopped)';
});
await check('AMD encoder in OBS log', async () => {
const log = await latestObsLog(paths.obsConfig);
if (!log) return 'no OBS log yet; start the worker once';
const lines = log
.split(/\r?\n/)
.filter((line) => /amf|AMD|encoder/i.test(line))
.slice(0, 12);
return lines.join(' | ') || 'no encoder lines in the latest OBS log';
});
desktop.stop();
await writeJson(paths.doctor, { at: new Date().toISOString(), checks });
return 0;
}