Files
esports/streamer/windows/worker.ts
Stan Runge 54444a77f0 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>
2026-10-18 22:02:25 +00:00

901 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}