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>
This commit is contained in:
169
streamer/windows/viewer.ts
Normal file
169
streamer/windows/viewer.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user