Files
esports/streamer/windows/desktop.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

279 lines
8.1 KiB
TypeScript

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