/** * 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 = { 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 { const { desktop } = options; let screen = { width: 1920, height: 1080 }; let capturing: Promise | 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; 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 ` ${title.replace(/[<>&]/g, '')}
${title.replace(/[<>&]/g, '')}Connecting…

Sign in to osu! with the spectator account and tick Remember username and Remember password. Click the screen to focus it; typing is then sent to the PC. Use the box below for passwords if your keyboard layout differs.

The broadcast PC's screen
`; }