- 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>
279 lines
8.1 KiB
TypeScript
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);
|
|
}
|
|
}
|