- 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>
170 lines
7.5 KiB
TypeScript
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>`;
|
|
}
|