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

170 lines
7.5 KiB
TypeScript

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